fix: review findings on the protocol work

Correctness:
- A child that stopped reading could freeze the whole client: the relay
  writes under the context lock and the socket was blocking, so one stalled
  peer took every download, upload and API call with it. Child writes now
  have a deadline, and a child that hits it loses its slot.
- Relaying searches to children was gated by the leaf's answer budget, so a
  parent forwarded a fraction of the stream while advertising itself as
  healthy. The budget bounds our own share scan, which is what it was for;
  the tree's stream goes down whole.
- A dropped client kept its listener port for the life of the process, so a
  retried login advertised an ephemeral port nobody was told about. The
  listener is now stopped and the operations thread let go.
- Excluded phrases were compared against a lowercased path without being
  lowercased themselves, so a capitalised phrase never matched. They are
  normalised where they arrive.
- Losing our own operatorship wiped the room's whole operator roster,
  though we are still a member who can see it.
- A brokered ConnectToPeer of type D was logged and dropped; the peer is
  now told, since we never dial out to adopt a child.
- A peer request made with no server connection returned Ok and parked
  forever; it says NotConnected, as its docs always claimed.
- An empty interest was refused locally but still sent, leaving the
  server's list and ours permanently apart.

Simplification:
- Deleted the excluded-phrase helper nothing called, and the field doc that
  promised behaviour it never had.
- The four roster handlers and four standing handlers were eight
  constructors carrying constants their own message code already implies:
  one newtype each, 100 lines less.
- One place registers a search; the phrase list is borrowed, not cloned per
  relayed search.
This commit is contained in:
Michel de Graaf
2026-09-09 18:38:34 +00:00
parent 435ff382d8
commit 6f32a4e090
11 changed files with 294 additions and 194 deletions
+42 -33
View File
@@ -315,6 +315,10 @@ pub enum ClientOperation {
username: String,
token: u32,
query: String,
/// True when it came down the tree from our parent, which has
/// already passed it to our children and is subject to the answer
/// budget. A search the server relayed is neither.
from_parent: bool,
},
/// A peer queued one of our shared files; `requester_key` is the registry
/// key of the peer actor — the peer's username.
@@ -555,8 +559,8 @@ pub struct ClientContext {
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// Phrases the server will not search for (code 160). A query carrying one
/// is refused locally rather than spent on the server.
/// Phrases the server excludes from the search network (code 160). Files
/// whose path carries one are left out of the replies we send.
excluded_search_phrases: Vec<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
@@ -779,15 +783,12 @@ impl ClientContext {
members,
granted,
} => {
// Our own standing is rendered from the same rosters, so a
// revocation must drop the room rather than leave a roster
// we are no longer part of looking current.
if !granted {
// Losing membership takes the operator roster with it:
// a room we cannot enter has no roster worth showing.
if *members {
self.private_room_members.remove(room);
}
// Losing membership takes both rosters with it: a room we
// cannot enter has none worth showing. Losing operatorship
// leaves us a member who still sees them, and the server
// narrates that roster change separately (code 144).
if !granted && *members {
self.private_room_members.remove(room);
self.private_room_operators.remove(room);
}
}
@@ -802,29 +803,45 @@ impl ClientContext {
/// Interests are matched by the server case-insensitively, and Nicotine+
/// lowercases them before sending; do the same so two spellings of one
/// interest cannot both be held.
pub fn add_own_interest(&mut self, item: &str, liked: bool) -> String {
/// Returns the stored spelling, or `None` for an empty item — which is
/// no interest, and must not be sent to the server either.
pub fn add_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
if !item.is_empty() && !list.contains(&item) {
if !list.contains(&item) {
list.push(item.clone());
}
item
Some(item)
}
/// Forget one of our own interests.
pub fn remove_own_interest(&mut self, item: &str, liked: bool) -> String {
/// Forget one of our own interests. `None` for an empty item, as above.
pub fn remove_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
list.retain(|held| held != &item);
item
Some(item)
}
/// What we like and hate, as last set.
@@ -1042,9 +1059,11 @@ impl ClientContext {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server refuses to search for (code 160).
/// Record the phrases the server excludes (code 160), lowercased here so
/// the per-file check is a plain `contains` on a lowercased path.
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases = phrases;
self.excluded_search_phrases =
phrases.iter().map(|p| p.to_lowercase()).collect();
}
/// The phrases the server refuses to search for.
@@ -1053,20 +1072,6 @@ impl ClientContext {
self.excluded_search_phrases.clone()
}
/// The first excluded phrase `query` contains, if any. Matching is
/// case-insensitive and on substrings, which is how the server applies its
/// own filter.
#[must_use]
pub fn excluded_phrase_in(&self, query: &str) -> Option<String> {
let haystack = query.to_lowercase();
self.excluded_search_phrases
.iter()
.find(|phrase| {
!phrase.is_empty() && haystack.contains(&phrase.to_lowercase())
})
.cloned()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {
@@ -1383,6 +1388,9 @@ pub struct Client {
server_handle: Option<ActorHandle<ServerMessage>>,
context: Arc<RwLock<ClientContext>>,
session: SessionWatch,
/// Tells the peer listener to stop, so a disconnected client releases the
/// port it bound.
listener_stopped: Arc<AtomicBool>,
}
impl Drop for Client {
@@ -1416,6 +1424,7 @@ impl Client {
shared_directories: settings.shared_directories,
server_handle: None,
session: SessionWatch::default(),
listener_stopped: Arc::new(AtomicBool::new(false)),
}
}