Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions desktop/src-tauri/src/commands/mesh_llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result<Vec<nostr::Even
let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?;
let member_pubkeys = mesh_llm::current_member_pubkeys(&events);
if member_pubkeys.is_empty() {
// Distinguish "relay returned a membership snapshot listing zero
// members" (authoritative empty — allowed to shrink the roster to
// self-only) from "no membership snapshot came back at all" (a
// transient gap / replication lag). The relay publishes an explicit
// kind:13534 event even for a zero-member community, so its absence
// means the query is incomplete: surface it as an error so the
// reconcile loop keeps the current allowlist instead of flapping the
// node down to self-only on a successful-but-empty response.
if !mesh_llm::has_membership_snapshot(&events) {
return Err("relay returned no membership snapshot".to_string());
}
return Ok(events);
}
let mut status_filter = mesh_llm::mesh_status_filter();
Expand All @@ -88,12 +99,25 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result<Vec<nostr::Even
}

/// Resolve the admission roster by intersecting member-signed mesh status
/// reporters with the current NIP-43 direct-member list. Missing membership or
/// a failed query returns an empty roster, which the runtime normalizes to
/// self-only admission.
pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Vec<String> {
match query_mesh_discovery_events(state).await {
Ok(events) => mesh_llm::owner_ids_from_events(&events),
/// reporters with the current NIP-43 direct-member list.
///
/// Returns `Err` when the relay query fails. Callers MUST distinguish this from
/// an `Ok(empty)` roster (a genuinely empty community): a failed query must
/// never be collapsed into "self-only", or a transient relay blip de-admits
/// every other member. `reconcile_roster` relies on this to keep the current
/// allowlist on error instead of restarting the node down to self-only.
pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Result<Vec<String>, String> {
let events = query_mesh_discovery_events(state).await?;
Ok(mesh_llm::owner_ids_from_events(&events))
}

/// Resolve the roster for an initial node *start*, failing closed to self-only
/// (an empty roster) when the relay query fails. This is safe only at start:
/// there is no established allowlist to preserve yet. The periodic
/// `reconcile_roster` path must NOT use this — it has a live roster to keep.
pub(crate) async fn resolve_trusted_owner_ids_or_self_only(state: &AppState) -> Vec<String> {
match resolve_trusted_owner_ids(state).await {
Ok(owners) => owners,
Err(error) => {
eprintln!("buzz-mesh: roster query failed; allowing only this node: {error}");
Vec::new()
Expand All @@ -117,7 +141,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C
model_id: Some(config.model_id),
max_vram_gb: config.max_vram_gb,
join_token: None,
trusted_owner_ids: Some(resolve_trusted_owner_ids(state).await),
trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
};
let started = mesh_llm::DesktopMeshRuntime::start(request)
.await
Expand All @@ -137,7 +161,7 @@ pub async fn mesh_start_node(
// Frontend requests never carry a roster; resolve it here so every
// UI-started node enforces the member allowlist.
if request.trusted_owner_ids.is_none() {
request.trusted_owner_ids = Some(resolve_trusted_owner_ids(&state).await);
request.trusted_owner_ids = Some(resolve_trusted_owner_ids_or_self_only(&state).await);
}
let mut runtime = state.mesh_llm_runtime.lock().await;
if runtime.is_some() {
Expand Down Expand Up @@ -268,7 +292,7 @@ pub(crate) async fn ensure_client_node_for_model(
model_id: None,
max_vram_gb: None,
join_token: Some(join_token),
trusted_owner_ids: Some(resolve_trusted_owner_ids(state).await),
trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
};
let mut runtime = state.mesh_llm_runtime.lock().await;
if runtime.is_some() {
Expand Down
189 changes: 182 additions & 7 deletions desktop/src-tauri/src/mesh_llm/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ pub async fn start_coordinator(app: AppHandle) {
});
let roster_app = app.clone();
let roster_watcher = tokio::spawn(async move {
// Carries a shrink awaiting confirmation across polls (hysteresis):
// a reduced roster must be seen twice in a row before we tear down.
let mut pending_shrink: Option<Vec<String>> = None;
loop {
tokio::time::sleep(ROSTER_POLL_INTERVAL).await;
let state = roster_app.state::<AppState>();
if let Err(error) = reconcile_roster(&state).await {
if let Err(error) = reconcile_roster(&state, &mut pending_shrink).await {
eprintln!("buzz-mesh: roster reconcile failed: {error}");
}
}
Expand All @@ -73,21 +76,108 @@ pub async fn start_coordinator(app: AppHandle) {
}
}

async fn reconcile_roster(state: &AppState) -> Result<(), String> {
/// Outcome of a roster reconcile decision.
#[derive(Debug, PartialEq, Eq)]
enum RosterReconcileAction {
/// Keep the running allowlist untouched (no-op, or a failure we ride out).
Keep,
/// Restart the node with a freshly resolved roster.
Restart(Vec<String>),
/// Observed a *shrink* (or empty) once. Hold the current allowlist and
/// require the same reduced roster on the next poll before tearing down,
/// so a single transient short-read never drops a member mid-inference.
AwaitConfirm(Vec<String>),
}

/// Whether `fresh` removes any owner present in `current` (a shrink), as
/// opposed to purely adding owners or leaving the set unchanged.
fn roster_shrinks(current: &[String], fresh: &[String]) -> bool {
current.iter().any(|owner| !fresh.contains(owner))
}

/// Pure decision for `reconcile_roster`, extracted so the transient-failure
/// and hysteresis invariants are unit-testable without a live relay.
///
/// `pending_shrink` is the reduced roster we are waiting to re-confirm (from a
/// prior poll's [`RosterReconcileAction::AwaitConfirm`]), if any.
///
/// Rules:
/// - query failed (`Err`) → `Keep` (never de-admit on a relay blip)
/// - resolved roster == current → `Keep` (no-op)
/// - grows (only additions) → `Restart` immediately (fast admission)
/// - shrinks/empties, first observation → `AwaitConfirm` (hold, re-check next poll)
/// - shrinks/empties, confirmed → `Restart` (same reduced roster twice)
fn roster_reconcile_action(
current_owners: &[String],
pending_shrink: Option<&[String]>,
query: Result<Vec<String>, String>,
) -> RosterReconcileAction {
let fresh = match query {
Err(error) => {
eprintln!(
"buzz-mesh: roster reconcile query failed; keeping current allowlist: {error}"
);
return RosterReconcileAction::Keep;
}
Ok(fresh) => fresh,
};

if fresh == current_owners {
return RosterReconcileAction::Keep;
}

// Growth (pure additions) is safe to apply immediately.
if !roster_shrinks(current_owners, &fresh) {
return RosterReconcileAction::Restart(fresh);
}

// A shrink (including down to empty) must be confirmed across two
// consecutive polls with the *same* reduced roster before we tear down.
match pending_shrink {
Some(pending) if pending == fresh => RosterReconcileAction::Restart(fresh),
_ => RosterReconcileAction::AwaitConfirm(fresh),
}
}

async fn reconcile_roster(
state: &AppState,
pending_shrink: &mut Option<Vec<String>>,
) -> Result<(), String> {
let current_request = {
let runtime = state.mesh_llm_runtime.lock().await;
match runtime.as_ref() {
Some(runtime) => runtime.start_request().clone(),
None => return Ok(()),
None => {
*pending_shrink = None;
return Ok(());
}
}
};
let Some(current_owners) = current_request.trusted_owner_ids.as_ref() else {
*pending_shrink = None;
return Ok(());
};
let fresh = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await;
if &fresh == current_owners {
return Ok(());
}
// A failed roster query must NOT be treated as "the roster became empty":
// doing so would restart the node down to self-only and de-admit every
// other member on a transient relay blip (the flapping restart loop). Keep
// the current allowlist and try again on the next poll. A shrink is held
// for one extra poll (hysteresis) so a single short-read never tears down.
let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await;
let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) {
RosterReconcileAction::Keep => {
*pending_shrink = None;
return Ok(());
}
RosterReconcileAction::AwaitConfirm(reduced) => {
eprintln!("buzz-mesh: roster shrink observed; awaiting confirmation before restart");
*pending_shrink = Some(reduced);
return Ok(());
}
RosterReconcileAction::Restart(fresh) => {
*pending_shrink = None;
fresh
}
};

let mut request = current_request;
request.trusted_owner_ids = Some(fresh);
Expand Down Expand Up @@ -227,6 +317,91 @@ mod tests {

use super::*;

// Regression: a transient roster-query failure must never restart the node
// down to self-only. Before the fix, `resolve_trusted_owner_ids` returned
// an empty Vec on error, which `reconcile_roster` read as "roster changed
// to empty" and restarted — de-admitting every other member and flapping
// the node on each relay blip. See #2000 follow-up.
#[test]
fn failed_roster_query_keeps_current_allowlist() {
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
let action = roster_reconcile_action(&current, None, Err("relay returned 503".to_string()));
assert_eq!(
action,
RosterReconcileAction::Keep,
"a failed query must keep the running allowlist, never de-admit members"
);
}

#[test]
fn unchanged_roster_is_a_noop() {
let current = vec!["owner-a".to_string()];
let action = roster_reconcile_action(&current, None, Ok(vec!["owner-a".to_string()]));
assert_eq!(action, RosterReconcileAction::Keep);
}

// Growth (pure additions) applies immediately — fast admission is fine.
#[test]
fn roster_growth_restarts_immediately() {
let current = vec!["owner-a".to_string()];
let fresh = vec!["owner-a".to_string(), "owner-c".to_string()];
let action = roster_reconcile_action(&current, None, Ok(fresh.clone()));
assert_eq!(action, RosterReconcileAction::Restart(fresh));
}

// A shrink is NOT applied on first observation — it must be confirmed.
#[test]
fn roster_shrink_awaits_confirmation_first() {
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
let reduced = vec!["owner-a".to_string()];
let action = roster_reconcile_action(&current, None, Ok(reduced.clone()));
assert_eq!(action, RosterReconcileAction::AwaitConfirm(reduced));
}

// The same reduced roster on two consecutive polls confirms the shrink.
#[test]
fn roster_shrink_restarts_once_confirmed() {
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
let reduced = vec!["owner-a".to_string()];
let action = roster_reconcile_action(&current, Some(&reduced), Ok(reduced.clone()));
assert_eq!(action, RosterReconcileAction::Restart(reduced));
}

// A shrink that changes between polls is not confirmed — it re-holds with
// the newly observed reduced roster instead of tearing down.
#[test]
fn roster_shrink_reconfirms_when_it_changes() {
let current = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let first_reduced = vec!["a".to_string(), "b".to_string()];
let second_reduced = vec!["a".to_string()];
let action =
roster_reconcile_action(&current, Some(&first_reduced), Ok(second_reduced.clone()));
assert_eq!(action, RosterReconcileAction::AwaitConfirm(second_reduced));
}

// A genuinely empty community (Ok(empty), distinct from a failed query)
// still shrinks to self-only — but only after confirmation.
#[test]
fn genuinely_empty_roster_awaits_then_restarts_to_self_only() {
let current = vec!["owner-a".to_string()];
let first = roster_reconcile_action(&current, None, Ok(Vec::new()));
assert_eq!(first, RosterReconcileAction::AwaitConfirm(Vec::new()));
let empty: Vec<String> = Vec::new();
let confirmed = roster_reconcile_action(&current, Some(&empty), Ok(Vec::new()));
assert_eq!(confirmed, RosterReconcileAction::Restart(Vec::new()));
}

// A shrink followed by recovery to the full roster cancels the teardown.
#[test]
fn roster_shrink_then_recovery_keeps_allowlist() {
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
let reduced = vec!["owner-a".to_string()];
let held = roster_reconcile_action(&current, None, Ok(reduced.clone()));
assert_eq!(held, RosterReconcileAction::AwaitConfirm(reduced.clone()));
let recovered = roster_reconcile_action(&current, Some(&reduced), Ok(current.clone()));
assert_eq!(recovered, RosterReconcileAction::Keep);
}

#[test]
fn member_heartbeat_leaves_room_before_admission_status_expires() {
assert!(
Expand Down
23 changes: 20 additions & 3 deletions desktop/src-tauri/src/mesh_llm/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ pub(crate) fn current_member_pubkeys(events: &[nostr::Event]) -> Vec<String> {
.unwrap_or_default()
}

/// Whether the relay actually returned a NIP-43 membership snapshot (kind
/// 13534) in `events`.
///
/// The relay publishes an explicit membership event even for a zero-member
/// community, so its presence is what makes an empty roster *authoritative*.
/// Callers use this to distinguish "the community genuinely has no members"
/// (snapshot present, zero `member` tags) from "no snapshot came back at all"
/// (a transient relay gap / replication lag). Only the former may shrink the
/// admission roster; the latter must be surfaced as an error so the reconcile
/// loop keeps the current allowlist instead of restarting to self-only.
pub(crate) fn has_membership_snapshot(events: &[nostr::Event]) -> bool {
events.iter().any(|event| event.kind.as_u16() == 13_534)
}

fn owner_id_from_status_event(event: &nostr::Event) -> Option<String> {
let content = serde_json::from_str::<serde_json::Value>(&event.content).ok()?;
let owner_id = content
Expand Down Expand Up @@ -200,11 +214,12 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
.unwrap_or_default()
.into_iter()
.filter_map(|mut target| {
let endpoint_id =
let validated =
super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr)
.ok()?;
target.endpoint_addr = validated.join_token;
if target.endpoint_id.is_none() {
target.endpoint_id = Some(endpoint_id);
target.endpoint_id = Some(validated.endpoint_id);
}
if target.device_id.is_none() {
target.device_id = target.endpoint_id.clone();
Expand Down Expand Up @@ -322,7 +337,9 @@ pub(super) fn device_name_from_status(
}

fn endpoint_id_from_invite_token(invite_token: &str) -> Option<String> {
super::transport_policy::validate_advertised_endpoint(invite_token).ok()
super::transport_policy::validate_advertised_endpoint(invite_token)
.ok()
.map(|validated| validated.endpoint_id)
}

fn string_value(value: &serde_json::Value, key: &str) -> Option<String> {
Expand Down
18 changes: 10 additions & 8 deletions desktop/src-tauri/src/mesh_llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ mod discovery;
pub use discovery::{
availability_from_events, mesh_status_filter, owner_ids_from_events, relay_membership_filter,
};
pub(crate) use discovery::{current_member_pubkeys, MESH_STATUS_PAGE_SIZE};
pub(crate) use discovery::{
current_member_pubkeys, has_membership_snapshot, MESH_STATUS_PAGE_SIZE,
};
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};

mod catalog;
Expand Down Expand Up @@ -270,8 +272,8 @@ async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> {
}

impl DesktopMeshRuntime {
pub async fn start(request: StartMeshNodeRequest) -> anyhow::Result<Self> {
validate_no_leak_request(&request)?;
pub async fn start(mut request: StartMeshNodeRequest) -> anyhow::Result<Self> {
sanitize_no_leak_request(&mut request)?;
initialize_mesh_native_runtime().await?;
let model_id = request
.model_id
Expand Down Expand Up @@ -420,8 +422,8 @@ impl DesktopMeshRuntime {

pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into<String>) -> anyhow::Result<()> {
let endpoint_addr = endpoint_addr.into();
validate_advertised_endpoint(&endpoint_addr)?;
self.handle.join_token(endpoint_addr).await
let validated = validate_advertised_endpoint(&endpoint_addr)?;
self.handle.join_token(validated.join_token).await
}

pub async fn installed_models(&self) -> anyhow::Result<Vec<MeshModelOption>> {
Expand Down Expand Up @@ -503,9 +505,9 @@ fn normalized_roster(
Some(owners)
}

fn validate_no_leak_request(request: &StartMeshNodeRequest) -> anyhow::Result<()> {
if let Some(join_token) = request.join_token.as_deref() {
validate_advertised_endpoint(join_token)?;
fn sanitize_no_leak_request(request: &mut StartMeshNodeRequest) -> anyhow::Result<()> {
if let Some(join_token) = request.join_token.as_mut() {
*join_token = validate_advertised_endpoint(join_token)?.join_token;
}
Ok(())
}
Expand Down
Loading