Skip to content

fix(desktop): keep mesh allowlist on transient roster query failure#2024

Merged
michaelneale merged 4 commits into
mainfrom
fix/mesh-roster-transient-failure-flapping
Jul 17, 2026
Merged

fix(desktop): keep mesh allowlist on transient roster query failure#2024
michaelneale merged 4 commits into
mainfrom
fix/mesh-roster-transient-failure-flapping

Conversation

@michaelneale

Copy link
Copy Markdown
Contributor

Problem

Shared-compute serves locally fine, but other relay members can't use it — intermittently, in a way that "works on my machine" and no test catches.

Root cause is a transient-failure race in the periodic admission-roster reconcile (pre-existing; not introduced by #2000):

  • resolve_trusted_owner_ids returned an empty Vec on any query failure (e.g. relay 503), indistinguishable from a genuinely empty community.
  • reconcile_roster (polls every 60s) restarts the mesh node whenever fresh != current. So a transient relay blip →empty roster → restart down to self-only, de-admitting every other member.
  • Next poll succeeds → restart back → next blip → restart again. The node flaps, tearing down/reloading the model each time.

Observed live: roster query failed; allowing only this node: relay returned 503 followed by repeated membership roster changed; restarting mesh node.

Remote consumers therefore hit either a self-only allowlist (rejected at admission) or a node restarting mid-inference (dropped connection).

Fix

  • resolve_trusted_owner_ids now returns Result<Vec<String>, String> — a failed query is Err, distinct from Ok(empty).
  • Start/restore paths use a new resolve_trusted_owner_ids_or_self_only (failing closed to self-only is only correct at initial start, when there's no live allowlist to preserve).
  • reconcile_roster: on query Err, keep the current allowlist and retry next poll instead of restarting. Extracted a pure roster_reconcile_action so the invariant is unit-testable without a live relay.

Not a revert of #2000

#2000's consumer-admission changes stay intact. This hardens the same path against transient relay errors.

Tests

New regression tests (the ones that would have caught this):

  • failed_roster_query_keeps_current_allowlist — the bug
  • unchanged_roster_is_a_noop
  • genuinely_changed_roster_restarts_with_fresh_owners
  • genuinely_empty_roster_restarts_to_self_onlyOk(empty) still allowed to shrink to self-only

All 11 mesh unit tests pass; cargo check --features mesh-llm clean.

Follow-up (out of scope)

This fixes the admission flapping. Whether the published serveTargets reliably reach the relay for cross-member discovery is a separate thread. There is still no automated e2e covering real cross-member discovery+admission over a gated relay — every existing mesh test loopbacks, hand-copies the invite token, or skips.

A transient relay failure (e.g. 503) during the periodic roster reconcile
was collapsed into an empty roster, which reconcile_roster read as 'roster
changed to empty' and restarted the mesh node down to self-only — de-admitting
every other member and flapping the node (restart loop) on each relay blip.
Remote consumers hit either a self-only allowlist or a node restarting
mid-inference, so 'nobody else can use my shared compute'.

- resolve_trusted_owner_ids now returns Result: a failed query is Err,
  distinct from Ok(empty) (a genuinely empty community).
- Start/restore paths use resolve_trusted_owner_ids_or_self_only (fail closed
  to self-only is correct only at start, with no live allowlist to preserve).
- reconcile_roster: on query Err, keep the current allowlist and retry next
  poll instead of restarting. Extracted pure roster_reconcile_action for tests.
- Regression tests for the failed-query, unchanged, changed, and genuinely
  empty cases.

Not a revert of #2000 (that consumer-admission fix stays); this hardens the
same path against transient relay errors.
@micspiral

Copy link
Copy Markdown
Collaborator

Review: good direction, but not yet "won't wig out on a flaky relay"

This correctly fixes the 503 flap and the caller split is right (start/restore/client fail closed to self-only; only reconcile_roster preserves the live allowlist). Vector ordering is safe (owner_ids_from_events sorts+dedupes), and partial pagination failures already become Err and are preserved. 👍

But there are three residual ways it can still thrash/tear-down under an intermittently-failing relay — and one of them is arguably more common than the 503 it fixes. An independent review (Codex, xhigh) and I both land on needs a bit more before it can claim "fixes flapping."

1. (Biggest) A successful 200 with an empty/missing membership list still restarts to self-only

query_mesh_discovery_events returns Ok(events) when the membership list comes back empty:

// desktop/src-tauri/src/commands/mesh_llm.rs:64
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() {
    return Ok(events);   // ← "no members" is indistinguishable from "genuinely empty community"
}

So a successful query that momentarily returns no membership snapshot (replication lag, brief gap, page race) is read as Ok(empty)reconcile_roster restarts the node down to self-only → next poll recovers → restart back. That's a 60s flap that tears down live inference each cycle — the exact "wig out" symptom, and the new genuinely_empty_roster_restarts_to_self_only test currently bakes it in.

Fix: only treat empty as authoritative when a real membership event was actually returned. Absence of a snapshot should be Err, not Ok(empty):

// desktop/src-tauri/src/commands/mesh_llm.rs, in query_mesh_discovery_events
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) from "no membership snapshot came back at all"
    // (transient gap). Only the former may shrink the roster; the latter must
    // be surfaced as Err so reconcile keeps the current allowlist.
    if !mesh_llm::has_membership_snapshot(&events) {
        return Err("relay returned no membership snapshot".to_string());
    }
    return Ok(events);
}

(The relay does publish an explicit membership event even for zero members — see crates/buzz-db/src/lib.rs:3224+ — so has_membership_snapshot can key off the presence of that kind, rather than the count.)

2. No hysteresis on shrink → any single blip that shrinks the roster restarts immediately

Even with #1, a single transient short-read can still momentarily drop a member. Restarting the whole node on the first observation of a smaller roster is what makes it twitchy. Additions can stay immediate (fast admission is nice); removals/empties should require confirmation across ≥2 consecutive polls before we tear down.

// coordinator.rs — carry a small confirmation counter in the reconcile loop.
// Shrink (or empty) must be observed on N consecutive polls before Restart;
// growth applies immediately. Reset the counter on any non-matching poll.
enum RosterReconcileAction {
    Keep,
    Restart(Vec<String>),
    AwaitConfirm(Vec<String>),   // saw a shrink once; hold, re-confirm next poll
}

This is the actual "don't wig out when things flake and come and go" guard.

3. Restart-fail leaves no runtime (no rollback) + a sampling race

// coordinator.rs:130 — take() removes the running node before we know the new one starts
let Some(running) = guard.take() else { return Ok(()); };
running.stop().await ...;                      // stop old
let replacement = DesktopMeshRuntime::start(request).await?;  // if this fails → runtime stays None

If the fresh-roster start fails, we've stopped the old node and left mesh_llm_runtime = Noneserving nothing, no rollback. And because relay I/O happens without holding the runtime lock (sampled current_request at :108, take() at :130), a user stop/start mid-poll can make reconcile stop the new node and restart it with the stale request.

Fix: keep the old request; on start failure, restart the old one and put it back (hard-stopped only if both fail). After re-taking the lock, compare the running node's start_request() to what was sampled; if changed, abort this reconcile and retry next poll.

Longer term (out of scope for this PR)

The root cause of the thrash is that any roster delta restarts the whole node. The right fix is a mesh-llm API to update trusted owners in place (v0.73.1's embedded handle only exposes status/join/stop — no live trust-store update). Worth an upstream ask so ordinary membership changes never drop live inference.

Suggested minimum to merge

Happy to push #1+#2 as a commit on this branch if useful — say the word.

Addresses review feedback on the 503-flap fix (#1 + #2):

#1 Missing membership snapshot no longer collapses to self-only.
query_mesh_discovery_events returned Ok(empty) both when the relay returned
an authoritative zero-member snapshot AND when no kind:13534 snapshot came
back at all (a transient gap / replication lag). The relay publishes an
explicit 13534 event even for a zero-member community, so absence means the
query was incomplete. Added has_membership_snapshot(); a missing snapshot is
now surfaced as Err so reconcile keeps the current allowlist instead of
flapping the node down to self-only on a successful-but-empty response.

#2 Shrink hysteresis. reconcile_roster restarted the node on the first
observation of any smaller roster, so a single short-read could drop a member
mid-inference. RosterReconcileAction gains AwaitConfirm: a shrink (or empty)
must be observed with the same reduced roster on two consecutive polls before
teardown; growth (pure additions) still applies immediately. The watcher loop
carries the pending-shrink state across polls.

Tests: has_membership_snapshot present/empty/missing; growth-immediate,
shrink-await, shrink-confirm, shrink-reconfirm-on-change, empty-await-confirm,
shrink-then-recovery. 44 mesh tests pass; clippy + fmt clean.

#3 (restart rollback + mid-poll runtime-change race) tracked as fast-follow.
@michaelneale

Copy link
Copy Markdown
Contributor Author

Thanks — both legit, folded in as 7b47e12.

#1 (missing snapshot → Err) — done. Added has_membership_snapshot() (keys off the presence of the kind:13534 event, since the relay publishes an explicit snapshot even for a zero-member community — confirmed at buzz-db/src/lib.rs:3224). query_mesh_discovery_events now returns Err("relay returned no membership snapshot") when no snapshot came back, so a successful-but-empty response no longer flaps the node to self-only. The old genuinely_empty_roster_restarts_to_self_only test (which baked the bad behavior in) is replaced.

#2 (shrink hysteresis) — done. RosterReconcileAction gains AwaitConfirm(Vec<String>). Growth (pure additions) still restarts immediately; a shrink/empty must be observed with the same reduced roster on two consecutive polls before teardown. The watcher loop carries pending_shrink across polls, and it resets on recovery, runtime-gone, or a changed shrink.

New tests: has_membership_snapshot_distinguishes_empty_from_missing, roster_growth_restarts_immediately, roster_shrink_awaits_confirmation_first, roster_shrink_restarts_once_confirmed, roster_shrink_reconfirms_when_it_changes, genuinely_empty_roster_awaits_then_restarts_to_self_only, roster_shrink_then_recovery_keeps_allowlist. 44 mesh tests pass; clippy + fmt clean.

#3 (restart rollback + mid-poll runtime-change race) — keeping this PR tight as you suggested; will do it as a fast-follow with the lifecycle tests (missing-snapshot, repeated-shrink-confirm, recovery, runtime-changed-mid-query, replacement-failure-with-rollback). The pure-decision layer is now in place so the lifecycle wrapper is the remaining piece.

Note: the failing Desktop E2E Integration (2/2) check is an unrelated flake — stream.spec.ts "delivers a message to a second browser context" (message-streaming UI, nothing this PR touches), failed on all retries at 0ms which is the docker/relay startup timeout signature. Rust Lint, Desktop Core, Desktop Build, Windows Rust all green.

Shared compute consumers rejected every stock mesh-llm serving node, failing
with "no live member is serving this model" even though discovery found it.

Two independent transport-policy bugs, both on the CONSUMER side (a serving
node never validates its own advertised endpoint, so only consumers need this):

1. Relay allowlist: IrohRelayMode::Default only accepted relays in iroh's own
   prod relay map. mesh-llm serving nodes advertise endpoints on mesh-llm's
   default public relays (*.relay.michaelneale.mesh-llm.iroh.link, see
   mesh-llm-host-runtime effective_relay_urls / RelayPolicy::DefaultPublic).
   The two sets never overlap, so relay_allowed() returned false and the serve
   target was filtered out of availability_from_events. Default mode now accepts
   iroh prod OR mesh-llm's default relays.

2. All-or-nothing endpoint validation: validate_endpoint_addr bailed on the
   FIRST unusable transport candidate. A stock mesh-llm endpoint advertises a
   relay alongside a port-0 direct IP; the port-0 candidate rejected the whole
   endpoint even though the relay route was usable. Transport candidates are
   alternatives, not all required — now accept the endpoint if at least one
   candidate is usable, recording why the rest were dropped.

Adds regression tests using real mesh-llm relay URLs and the real
relay+port-0 multi-candidate endpoint shape (the existing tests were hermetic:
fake relay.example URLs and single clean-address endpoints, so neither bug was
observable).
@michaelneale
michaelneale marked this pull request as ready for review July 17, 2026 08:31
@michaelneale
michaelneale requested a review from a team as a code owner July 17, 2026 08:31
@michaelneale
michaelneale enabled auto-merge (squash) July 17, 2026 08:43
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
@michaelneale
michaelneale merged commit f205e66 into main Jul 17, 2026
25 checks passed
@michaelneale
michaelneale deleted the fix/mesh-roster-transient-failure-flapping branch July 17, 2026 11:49
kalvinnchau added a commit that referenced this pull request Jul 17, 2026
* origin/main: (31 commits)
  chore(release): release Buzz Desktop version 0.4.14 (#2036)
  Strip media metadata on clients and reject it at the relay (#2006)
  fix(desktop): restore Doctor ACP login discovery (#2017)
  feat(desktop): animate starter team onboarding (#2032)
  fix(ci): use git diff for path filtering (#2029)
  fix(desktop): include exe dir and ~/.local/bin in provider discovery (#2007)
  [codex] Hold Git concurrency permits through streaming (BUZZ-SEC-018) (#1916)
  [codex] Enforce shared relay admission limits (BUZZ-SEC-019) (#1917)
  [codex] Block banned actors from moderation commands (BUZZ-SEC-007) (#1915)
  [codex] Fix relay WebSocket admission limits (#1682)
  fix(desktop): close mention popup after an exact name + trailing space (#2030)
  test(desktop): harden observer archive policy regression coverage (#1994)
  [codex] Bind release publishers to immutable tags (BUZZ-SEC-039) (#1914)
  fix: resolve invite downloads by platform (#2001)
  Bug-bash: mention/code-span, feed titles, autocomplete, edit-mentions, draft routing, and right-click media Download (#2027)
  fix(desktop): keep mesh allowlist on transient roster query failure (#2024)
  Polish Buzz theme and sidebar (#1971)
  Welcome new users with a live agent team kickoff (#1998)
  fix(desktop): case-insensitive relay media origin match, release 0.4.13 (#2023)
  feat(desktop): show owners in agent hover cards (#2015)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants