fix(friends): five robustness bugs in the friends/control plane

Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:

- Host child dying on its own left the share campaign running, so it kept
  pushing a now-dead ticket to friends (retrying offline ones forever) and
  leaked share_status/met/share_code. The unexpected-exit path now captures
  the stderr error, then routes through the full stop_host() teardown
  (notably stop_share). (gui/mod.rs pump_host_events)

- on_friend_request downgraded an already-Accepted friend back to
  PendingIncoming when they re-sent a request (e.g. after losing their
  store). It now stays Accepted and re-confirms. (friends.rs)

- on_friend_accept advanced *any* known peer to Accepted, including a
  PendingIncoming one — a peer could mark itself accepted without the local
  user's consent. Now only a PendingOutgoing request we sent is honoured.
  (friends.rs)

- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
  notification. push_notice now reports whether the code is new/changed and
  only then toasts. (gui/mod.rs)

- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
  link because handle() awaited the sender's close before forwarding it.
  Forward to the UI first, then await close so the ACK still flushes.
  (control.rs)

Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 15:10:22 -04:00
parent 04bc0a808a
commit 6d0bf99076
3 changed files with 97 additions and 39 deletions
+7 -4
View File
@@ -166,13 +166,16 @@ async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
.await .await
.context("timed out reading control message")??; .context("timed out reading control message")??;
// Wait (briefly) for the sender's close so our ACK flushes before the // Hand the message up first, so it reaches the UI promptly even when the
// connection is dropped at the end of this scope. // sender is slow to close (a degraded link could otherwise delay a friend
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await; // request / pushed code by up to IO_TIMEOUT).
tx.send(Inbound { from, msg }) tx.send(Inbound { from, msg })
.await .await
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?; .map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
// Then wait (briefly) for the sender's close so our ACK has flushed before
// the connection is dropped at the end of this scope.
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
Ok(()) Ok(())
} }
+53 -18
View File
@@ -147,35 +147,45 @@ impl FriendStore {
self.friends.len() != before self.friends.len() != before
} }
/// Apply an inbound friend request. Returns `true` if it *completes a mutual /// Apply an inbound friend request. Returns `true` if the friendship is now
/// match* — we'd already sent them one, so they're now [`Accepted`] and the /// settled at [`Accepted`] and the caller should reply with a `FriendAccept`
/// caller should reply with a `FriendAccept`. Otherwise it's recorded as /// — either because we'd already sent them a request (a mutual match) or
/// because they're an existing friend re-announcing (we never downgrade an
/// [`Accepted`] friend back to pending; a peer who lost their store and
/// re-adds us just gets re-confirmed). Otherwise it's recorded as
/// [`PendingIncoming`] for the user to act on and `false` is returned. /// [`PendingIncoming`] for the user to act on and `false` is returned.
/// ///
/// [`Accepted`]: FriendState::Accepted /// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming /// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool { pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
match self.find(&id).map(|f| f.state) {
Some(FriendState::PendingOutgoing | FriendState::Accepted) => {
self.upsert(id, name, FriendState::Accepted);
true
}
_ => {
self.upsert(id, name, FriendState::PendingIncoming);
false
}
}
}
/// Apply an inbound acceptance of a request we sent. Returns `true` only if
/// it advanced one of *our* outgoing requests to [`Accepted`]. An accept for
/// any other state is ignored: a stranger's, or one for a peer still in
/// [`PendingIncoming`] (their request, awaiting our decision) — honouring the
/// latter would let a peer mark itself accepted without the local user's
/// consent.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
if matches!( if matches!(
self.find(&id).map(|f| f.state), self.find(&id).map(|f| f.state),
Some(FriendState::PendingOutgoing) Some(FriendState::PendingOutgoing)
) { ) {
self.upsert(id, name, FriendState::Accepted); self.upsert(id, name, FriendState::Accepted);
true true
} else {
self.upsert(id, name, FriendState::PendingIncoming);
false
}
}
/// Apply an inbound acceptance of a request we sent. Returns `true` if it
/// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer);
/// an accept from a stranger is ignored.
///
/// [`Accepted`]: FriendState::Accepted
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
if self.find(&id).is_some() {
self.upsert(id, name, FriendState::Accepted);
true
} else { } else {
false false
} }
@@ -293,4 +303,29 @@ mod tests {
assert!(!store.on_friend_accept(stranger, "Nope".into())); assert!(!store.on_friend_accept(stranger, "Nope".into()));
assert!(store.find(&stranger).is_none()); assert!(store.find(&stranger).is_none());
} }
#[test]
fn accept_does_not_advance_a_pending_incoming_peer() {
// They asked us and we haven't decided yet; an unsolicited FriendAccept
// from them must not auto-accept on our behalf (consent bypass).
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Theirs".into(), FriendState::PendingIncoming);
assert!(!store.on_friend_accept(id, "Theirs".into()));
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
}
#[test]
fn request_does_not_downgrade_an_accepted_friend() {
// A current friend re-sending a request (e.g. after losing their store)
// must stay accepted; the call signals a re-confirm rather than a
// downgrade to pending.
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Pal".into(), FriendState::Accepted);
let settled = store.on_friend_request(id, "Pal (reinstalled)".into());
assert!(settled);
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
assert_eq!(store.find(&id).unwrap().name, "Pal (reinstalled)");
}
} }
+37 -17
View File
@@ -1145,11 +1145,14 @@ impl PixelPassApp {
f.name = name.clone(); f.name = name.clone();
store_changed = true; store_changed = true;
} }
self.push_notice(from, name.clone(), ticket); // Only toast for a new/changed code — an ACK-loss retry
notify( // redelivers the same code and shouldn't fire again.
"PixelPass — a friend is sharing", if self.push_notice(from, name.clone(), ticket) {
format!("{name} is sharing their screen. Open PixelPass to watch."), notify(
); "PixelPass — a friend is sharing",
format!("{name} is sharing their screen. Open PixelPass to watch."),
);
}
} else { } else {
tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend"); tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend");
} }
@@ -1197,13 +1200,20 @@ impl PixelPassApp {
} }
/// Record a share code a friend pushed us, replacing any prior notice from /// Record a share code a friend pushed us, replacing any prior notice from
/// the same friend (their previous code is stale once they re-host). /// the same friend (their previous code is stale once they re-host). Returns
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) { /// `true` if this is a new notice or a *different* code than we already had
/// from them — i.e. worth a fresh desktop notification. A duplicate delivery
/// (an ACK-loss retry redelivering the same code) updates in place and
/// returns `false`, so it doesn't fire a second toast.
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) -> bool {
if let Some(n) = self.notices.iter_mut().find(|n| n.from == from) { if let Some(n) = self.notices.iter_mut().find(|n| n.from == from) {
let changed = n.code != code;
n.name = name; n.name = name;
n.code = code; n.code = code;
changed
} else { } else {
self.notices.push(ShareNotice { from, name, code }); self.notices.push(ShareNotice { from, name, code });
true
} }
} }
@@ -2318,19 +2328,29 @@ impl PixelPassApp {
self.apply_host_event(ev); self.apply_host_event(ev);
} }
if let Some(p) = &mut self.host.proc let dead = self.host.proc.as_mut().is_some_and(|p| !p.is_alive());
&& !p.is_alive() if dead {
{ // If it never reached a ticket, capture why (from the stderr tail)
if self.host.ticket.is_none() { // before tearing down. Then run the *full* Stop cleanup — most
let tail = p.stderr_tail(); // importantly stop_share, so a host that died on its own stops
self.host.error = Some(if tail.trim().is_empty() { // pushing its now-dead code to friends. Without this the campaign
// would keep retrying offline friends with a stale ticket for the
// life of the GUI, and share_status/met/share_code would leak.
let error = self.host.ticket.is_none().then(|| {
let tail = self
.host
.proc
.as_mut()
.map(|p| p.stderr_tail())
.unwrap_or_default();
if tail.trim().is_empty() {
"Host exited before it could start.".to_string() "Host exited before it could start.".to_string()
} else { } else {
format!("Host exited before it could start:\n{tail}") format!("Host exited before it could start:\n{tail}")
}); }
} });
self.host.proc = None; self.stop_host();
self.host.capturing = false; self.host.error = error;
} }
} }