fix(lib): harden the distributed leaf against hostile and slow parents

Review of the leaf found the seams a stranger on the other end of a D
connection could pull at, and a few that a slow one would.

- A code-93 envelope is unwrapped once, as Nicotine+ does; a frame made
  of nothing but envelopes used to recurse once per byte.
- A branch level is validated before it is used: negative levels leave
  the candidate unadoptable and the largest one no longer overflows.
- The link reads through the crate's MessageReader, so a frame split
  across a read timeout is no longer torn or mistaken for a closed link.
- Every event a link raises carries its link number, so a stale thread
  cannot adopt, close or move a newer link to the same user.
- Later branch facts from the parent move our own place and are told to
  the server; a fresh login resets the leaf, whose stance is now the one
  place the four announcement messages come from.
- Candidates from a later batch join the ones in flight instead of
  cancelling them, capped at ten; searches from the parent are budgeted
  at fifty a second; a reply queued for a searcher the server cannot
  place expires after a minute.
- Truncated frames and half-read PossibleParents entries are dropped
  instead of yielding an empty root, a level of zero or a phantom
  candidate.

The e2e now has a searcher of its own, so the answer must go to whoever
asked and not to the parent that relayed it.
This commit is contained in:
Michel de Graaf
2026-09-08 01:45:07 +02:00
parent 7d7b8fe98c
commit 9d6a5aaa98
9 changed files with 589 additions and 207 deletions
+344 -127
View File
@@ -3,18 +3,18 @@
//! server when that changes.
use super::{Client, ClientOperation, Result, ServerMessage};
use crate::message::Message;
use crate::message::MessageReader;
use crate::message::distributed::{self, Distributed};
use crate::message::server::MessageFactory;
use crate::peer::ConnectionType;
use crate::utils::lock::RwLockExt;
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::io::{self, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::Duration;
use std::time::{Duration, Instant};
/// A candidate that has not answered the dial in this long is not a parent.
const LINK_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -23,6 +23,13 @@ const CANCEL_POLL: Duration = Duration::from_secs(1);
/// A distributed frame is a search or a branch fact; anything bigger is not
/// one of ours.
const MAX_FRAME: usize = 64 * 1024;
/// The server offers at most ten parents at a time; more candidates than
/// that in flight is a server that keeps offering, and each one is a thread.
const MAX_CANDIDATES: usize = 10;
/// Searches admitted from the parent per second. Every search costs a scan
/// of everything we share, and the tree carries the whole network's stream.
// ponytail: a flat budget; a word index over the shares is the upgrade path.
const SEARCH_BUDGET: u32 = 50;
/// Where we sit in the tree, as told to the server.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -46,15 +53,17 @@ impl Branch {
}
}
#[derive(Default)]
struct Candidate {
level: Option<i32>,
link: u64,
level: Option<u32>,
root: Option<String>,
cancel: Arc<AtomicBool>,
}
/// A candidate to dial, and the flag that tells its link to stop.
/// A candidate to dial: the link number that tags everything it sends, and
/// the flag that tells its thread to stop.
pub struct Dial {
pub link: u64,
pub username: String,
pub host: String,
pub port: u16,
@@ -63,12 +72,16 @@ pub struct Dial {
/// The leaf's view: at most one parent, adopted from the candidates the
/// server offered, the way Nicotine+ does it. Every method is a pure state
/// change so the rule is testable without a socket.
/// change so the rule is testable without a socket. Events carry the link
/// number they came from, so a stale thread cannot speak for a newer link
/// to the same user.
pub struct Leaf {
own: String,
parent: Option<String>,
branch: Branch,
candidates: HashMap<String, Candidate>,
next_link: u64,
searches: (Instant, u32),
}
impl Leaf {
@@ -79,6 +92,8 @@ impl Leaf {
parent: None,
branch: Branch::parentless(own),
candidates: HashMap::new(),
next_link: 1,
searches: (Instant::now(), 0),
}
}
@@ -87,13 +102,15 @@ impl Leaf {
self.branch.clone()
}
/// Whether `link` is the live link to our parent.
#[must_use]
pub fn is_parent(&self, username: &str) -> bool {
pub fn is_parent(&self, username: &str, link: u64) -> bool {
self.parent.as_deref() == Some(username)
&& self.has_link(username, link)
}
/// New candidates: dial them all, unless we already hang from a parent.
/// Candidates still being sounded out from an earlier batch are dropped.
/// New candidates to dial: those we are not already sounding out, up to
/// the cap, and none at all while we hang from a parent.
pub fn consider(
&mut self,
candidates: Vec<(String, String, u16)>,
@@ -101,50 +118,74 @@ impl Leaf {
if self.parent.is_some() {
return Vec::new();
}
self.cancel_candidates();
candidates
.into_iter()
.map(|(username, host, port)| {
let cancel = Arc::new(AtomicBool::new(false));
self.candidates.insert(
username.clone(),
Candidate {
cancel: cancel.clone(),
..Candidate::default()
},
);
Dial {
username,
host,
port,
cancel,
}
})
.collect()
let mut dials = Vec::new();
for (username, host, port) in candidates {
if self.candidates.contains_key(&username)
|| self.candidates.len() >= MAX_CANDIDATES
{
continue;
}
let link = self.next_link;
self.next_link += 1;
let cancel = Arc::new(AtomicBool::new(false));
self.candidates.insert(
username.clone(),
Candidate {
link,
level: None,
root: None,
cancel: cancel.clone(),
},
);
dials.push(Dial {
link,
username,
host,
port,
cancel,
});
}
dials
}
/// A branch root does not always say so separately: level 0 is its own
/// root.
pub fn branch_level(&mut self, username: &str, level: i32) {
let Some(candidate) = self.candidates.get_mut(username) else {
return;
};
candidate.level = Some(level);
/// A candidate said how deep it sits. A branch root does not always say
/// so separately: level 0 is its own root. A negative level is nonsense
/// and leaves the candidate unadoptable. When it is the parent talking,
/// our own place moves with it and the new stance comes back.
pub fn branch_level(
&mut self,
username: &str,
link: u64,
level: i32,
) -> Option<Branch> {
if !self.has_link(username, link) {
return None;
}
let candidate = self.candidates.get_mut(username)?;
candidate.level = u32::try_from(level).ok();
if level == 0 && candidate.root.is_none() {
candidate.root = Some(username.to_string());
}
self.moved_with_parent(username)
}
pub fn branch_root(&mut self, username: &str, root: &str) {
if let Some(candidate) = self.candidates.get_mut(username) {
candidate.root = Some(root.to_string());
pub fn branch_root(
&mut self,
username: &str,
link: u64,
root: &str,
) -> Option<Branch> {
if !self.has_link(username, link) {
return None;
}
self.candidates.get_mut(username)?.root = Some(root.to_string());
self.moved_with_parent(username)
}
/// A search came down from `username`. The first one from a candidate that
/// has told us its place adopts it; the stance to announce comes back.
pub fn search_from(&mut self, username: &str) -> Option<Branch> {
if self.parent.is_some() {
/// A search came down from `username`. The first one from a candidate
/// that has told us its place adopts it; the stance to announce comes back.
pub fn search_from(&mut self, username: &str, link: u64) -> Option<Branch> {
if self.parent.is_some() || !self.has_link(username, link) {
return None;
}
let candidate = self.candidates.get(username)?;
@@ -157,16 +198,28 @@ impl Leaf {
self.cancel_candidates();
self.candidates.insert(username.to_string(), parent);
self.parent = Some(username.to_string());
self.branch =
Branch::under(&root, u32::try_from(level + 1).unwrap_or(1));
self.branch = Branch::under(&root, level.saturating_add(1));
Some(self.branch.clone())
}
/// Whether to answer one more search from the parent now: at most
/// `SEARCH_BUDGET` a second, the rest dropped.
pub fn admit_search(&mut self, now: Instant) -> bool {
if now.duration_since(self.searches.0) >= Duration::from_secs(1) {
self.searches = (now, 0);
}
self.searches.1 += 1;
self.searches.1 <= SEARCH_BUDGET
}
/// The link to `username` closed. True when that was the parent, which
/// puts us back to parentless.
pub fn closed(&mut self, username: &str) -> bool {
pub fn closed(&mut self, username: &str, link: u64) -> bool {
if !self.has_link(username, link) {
return false;
}
self.candidates.remove(username);
if !self.is_parent(username) {
if self.parent.as_deref() != Some(username) {
return false;
}
self.parent = None;
@@ -180,6 +233,28 @@ impl Leaf {
self.branch = Branch::parentless(&self.own);
}
fn has_link(&self, username: &str, link: u64) -> bool {
self.candidates
.get(username)
.is_some_and(|c| c.link == link)
}
fn moved_with_parent(&mut self, username: &str) -> Option<Branch> {
if self.parent.as_deref() != Some(username) {
return None;
}
let candidate = self.candidates.get(username)?;
let branch = Branch::under(
candidate.root.as_deref()?,
candidate.level?.saturating_add(1),
);
if branch == self.branch {
return None;
}
self.branch = branch.clone();
Some(branch)
}
fn cancel_candidates(&mut self) {
for candidate in self.candidates.values() {
candidate.cancel.store(true, Ordering::Relaxed);
@@ -205,17 +280,31 @@ pub fn announce(
/// Dial a parent candidate on its own thread: a `D` PeerInit, then whatever it
/// sends comes back as operations until the link closes or is cancelled.
pub fn spawn_link(dial: Dial, own: String, ops: Sender<ClientOperation>) {
let _ = std::thread::Builder::new()
let closed = ClientOperation::ParentClosed {
parent: dial.username.clone(),
link: dial.link,
};
let thread_ops = ops.clone();
let spawned = std::thread::Builder::new()
.name("soulseek-parent".to_string())
.spawn(move || {
let parent = dial.username.clone();
if let Err(e) = link(&dial, &own, &ops)
if let Err(e) = link(&dial, &own, &thread_ops)
&& !dial.cancel.load(Ordering::Relaxed)
{
crate::debug!("[distributed] link to {} ended: {}", parent, e);
let _ = ops.send(ClientOperation::ParentClosed { parent });
crate::debug!(
"[distributed] link to {} ended: {}",
dial.username,
e
);
let _ = thread_ops.send(ClientOperation::ParentClosed {
parent: dial.username,
link: dial.link,
});
}
});
if spawned.is_err() {
let _ = ops.send(closed);
}
}
fn link(
@@ -230,70 +319,75 @@ fn link(
io::Error::new(io::ErrorKind::InvalidInput, "no address")
})?;
let mut stream = TcpStream::connect_timeout(&addr, LINK_CONNECT_TIMEOUT)?;
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
stream.set_read_timeout(Some(CANCEL_POLL))?;
stream.write_all(
&MessageFactory::build_peer_init_message(own, ConnectionType::D, 0)
.get_buffer(),
)?;
let mut reader = MessageReader::new();
loop {
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
let Some(mut frame) = read_frame(&mut stream)? else {
continue;
};
let parent = dial.username.clone();
let operation = match distributed::parse(&mut frame) {
Some(Distributed::BranchLevel(level)) => {
ClientOperation::ParentBranchLevel { parent, level }
match reader.read_from_socket(&mut stream) {
Ok(()) => {}
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
continue;
}
Some(Distributed::BranchRoot(root)) => {
ClientOperation::ParentBranchRoot { parent, root }
Err(e) => return Err(e),
}
while let Some(mut frame) = reader.extract_message()? {
let Some(operation) = operation_of(dial, &mut frame) else {
continue;
};
if ops.send(operation).is_err() {
return Ok(());
}
Some(Distributed::Search {
username,
token,
query,
}) => ClientOperation::ParentSearch {
parent,
username,
token,
query,
},
None => continue,
};
if ops.send(operation).is_err() {
return Ok(());
}
if reader.buffer_len() > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"oversized frame",
));
}
}
}
/// One framed message, or `None` when the read timed out with nothing sent.
fn read_frame(stream: &mut TcpStream) -> io::Result<Option<Message>> {
let mut len = [0u8; 4];
match stream.read_exact(&mut len) {
Ok(()) => {}
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
return Ok(None);
fn operation_of(
dial: &Dial,
frame: &mut crate::message::Message,
) -> Option<ClientOperation> {
let parent = dial.username.clone();
let link = dial.link;
Some(match distributed::parse(frame)? {
Distributed::BranchLevel(level) => ClientOperation::ParentBranchLevel {
parent,
link,
level,
},
Distributed::BranchRoot(root) => {
ClientOperation::ParentBranchRoot { parent, link, root }
}
Err(e) => return Err(e),
}
let size = u32::from_le_bytes(len) as usize;
if size > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"oversized frame",
));
}
let mut data = len.to_vec();
data.resize(4 + size, 0);
stream.read_exact(&mut data[4..])?;
Ok(Some(Message::new_with_data(data)))
Distributed::Search {
username,
token,
query,
} => ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
},
})
}
impl Client {
@@ -339,6 +433,18 @@ mod tests {
Leaf::new("me")
}
fn candidate(name: &str) -> (String, String, u16) {
(name.to_string(), "127.0.0.1".to_string(), 2234)
}
/// Dial the candidates and return their link numbers by name.
fn dial(leaf: &mut Leaf, names: &[&str]) -> HashMap<String, Dial> {
leaf.consider(names.iter().map(|n| candidate(n)).collect())
.into_iter()
.map(|d| (d.username.clone(), d))
.collect()
}
#[test]
fn a_leaf_without_a_parent_is_its_own_root_at_level_zero() {
assert_eq!(leaf().branch(), Branch::parentless("me"));
@@ -347,12 +453,13 @@ mod tests {
#[test]
fn candidates_are_dialled_only_while_there_is_no_parent() {
let mut leaf = leaf();
let dial = leaf.consider(vec![candidate("alice"), candidate("bob")]);
assert_eq!(dial.len(), 2);
leaf.branch_level("alice", 1);
leaf.branch_root("alice", "rooty");
let dials = dial(&mut leaf, &["alice", "bob"]);
assert_eq!(dials.len(), 2);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 1);
leaf.branch_root("alice", alice, "rooty");
assert!(
leaf.search_from("alice").is_some(),
leaf.search_from("alice", alice).is_some(),
"alice becomes the parent"
);
assert!(leaf.consider(vec![candidate("carol")]).is_empty());
@@ -361,48 +468,158 @@ mod tests {
#[test]
fn the_first_search_from_a_candidate_with_branch_info_adopts_it() {
let mut leaf = leaf();
leaf.consider(vec![candidate("alice"), candidate("bob")]);
assert_eq!(leaf.search_from("alice"), None, "no branch info yet");
leaf.branch_level("alice", 3);
assert_eq!(leaf.search_from("alice"), None, "level without root");
leaf.branch_root("alice", "rooty");
let dials = dial(&mut leaf, &["alice", "bob"]);
let (alice, bob) = (dials["alice"].link, dials["bob"].link);
assert_eq!(leaf.search_from("alice", alice), None, "no branch yet");
leaf.branch_level("alice", alice, 3);
assert_eq!(leaf.search_from("alice", alice), None, "level, no root");
leaf.branch_root("alice", alice, "rooty");
assert_eq!(
leaf.search_from("alice"),
leaf.search_from("alice", alice),
Some(Branch::under("rooty", 4)),
"level 3 parent puts us at level 4"
);
assert_eq!(leaf.branch(), Branch::under("rooty", 4));
assert_eq!(leaf.search_from("alice"), None, "already the parent");
assert_eq!(leaf.search_from("bob"), None, "a loser is ignored");
assert!(leaf.is_parent("alice", alice));
assert_eq!(leaf.search_from("alice", alice), None, "already parent");
assert_eq!(leaf.search_from("bob", bob), None, "a loser is ignored");
assert!(
dials["bob"].cancel.load(Ordering::Relaxed),
"the losing link is told to stop"
);
assert!(!dials["alice"].cancel.load(Ordering::Relaxed));
}
#[test]
fn a_level_zero_candidate_is_its_own_root() {
let mut leaf = leaf();
leaf.consider(vec![candidate("alice")]);
leaf.branch_level("alice", 0);
assert_eq!(leaf.search_from("alice"), Some(Branch::under("alice", 1)));
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("alice", 1))
);
}
#[test]
fn a_hostile_branch_level_neither_panics_nor_adopts() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_root("alice", alice, "rooty");
leaf.branch_level("alice", alice, -1);
assert_eq!(leaf.search_from("alice", alice), None, "negative level");
leaf.branch_level("alice", alice, i32::MAX);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("rooty", 1 << 31))
);
}
#[test]
fn the_parents_later_moves_move_us_too() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
assert_eq!(leaf.branch_level("alice", alice, 0), None, "unchanged");
assert_eq!(
leaf.branch_root("alice", alice, "grand"),
Some(Branch::under("grand", 1))
);
assert_eq!(
leaf.branch_level("alice", alice, 2),
Some(Branch::under("grand", 3))
);
assert_eq!(leaf.branch(), Branch::under("grand", 3));
}
#[test]
fn a_stale_link_cannot_speak_for_a_newer_one() {
let mut leaf = leaf();
let old = dial(&mut leaf, &["alice"])["alice"].link;
assert!(!leaf.closed("alice", old + 7), "unknown link is ignored");
leaf.closed("alice", old);
let new = dial(&mut leaf, &["alice"])["alice"].link;
assert_ne!(old, new);
leaf.branch_level("alice", old, 0);
assert_eq!(leaf.search_from("alice", old), None, "old link ignored");
assert!(
!leaf.closed("alice", old),
"a late close leaves the new one"
);
leaf.branch_level("alice", new, 0);
assert!(leaf.search_from("alice", new).is_some());
}
#[test]
fn a_later_batch_keeps_the_candidates_in_flight_and_stays_under_the_cap() {
let mut leaf = leaf();
let first = dial(&mut leaf, &["alice"])["alice"].link;
let again = dial(&mut leaf, &["alice", "bob"]);
assert!(
!again.contains_key("alice"),
"alice is already being dialled"
);
assert!(again.contains_key("bob"));
assert!(leaf.has_link("alice", first));
let names: Vec<String> = (0..20).map(|i| format!("p{i}")).collect();
let more = leaf
.consider(names.iter().map(|n| candidate(n)).collect::<Vec<_>>());
assert_eq!(more.len(), MAX_CANDIDATES - 2);
}
#[test]
fn losing_the_parent_or_a_reset_returns_to_parentless() {
let mut leaf = leaf();
leaf.consider(vec![candidate("alice")]);
leaf.branch_level("alice", 0);
leaf.search_from("alice");
assert!(!leaf.closed("bob"), "a stranger closing changes nothing");
assert!(leaf.closed("alice"));
let dials = dial(&mut leaf, &["alice", "bob"]);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
assert!(!leaf.closed("bob", dials["bob"].link), "not the parent");
assert!(leaf.closed("alice", alice));
assert_eq!(leaf.branch(), Branch::parentless("me"));
leaf.consider(vec![candidate("alice")]);
leaf.branch_level("alice", 0);
leaf.search_from("alice");
let dials = dial(&mut leaf, &["alice", "carol"]);
let alice = dials["alice"].link;
leaf.branch_level("alice", alice, 0);
leaf.search_from("alice", alice);
leaf.reset();
assert_eq!(leaf.branch(), Branch::parentless("me"));
assert!(dials["alice"].cancel.load(Ordering::Relaxed));
assert!(dials["carol"].cancel.load(Ordering::Relaxed));
assert!(!leaf.consider(vec![candidate("bob")]).is_empty());
}
fn candidate(name: &str) -> (String, String, u16) {
(name.to_string(), "127.0.0.1".to_string(), 2234)
#[test]
fn searches_beyond_the_budget_are_dropped_until_the_next_second() {
let mut leaf = leaf();
let now = Instant::now();
for _ in 0..SEARCH_BUDGET {
assert!(leaf.admit_search(now));
}
assert!(!leaf.admit_search(now));
assert!(leaf.admit_search(now + Duration::from_secs(1)));
}
#[test]
fn announcing_tells_the_server_the_root_the_level_and_that_we_have_a_parent()
{
let (tx, rx) = std::sync::mpsc::channel();
announce(Some(&tx), &Branch::under("rooty", 4), true);
let mut sent: Vec<crate::message::Message> = Vec::new();
while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() {
sent.push(m);
}
let codes: Vec<u32> = sent
.iter()
.map(|m| u32::from_le_bytes(m.get_data()[..4].try_into().unwrap()))
.collect();
assert_eq!(codes, vec![71, 127, 126, 100]);
assert_eq!(sent[0].get_data()[4], 0, "HaveNoParent is false");
let mut root = sent[1].clone();
root.set_pointer(4);
assert_eq!(root.read_string(), "rooty");
assert_eq!(&sent[2].get_data()[4..8], &4u32.to_le_bytes());
assert_eq!(sent[3].get_data()[4], 0, "AcceptChildren is false");
}
}
+35 -5
View File
@@ -60,6 +60,8 @@ pub const DEFAULT_UPLOAD_SLOTS: usize = 10;
const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const DEFAULT_MAX_PEERS: usize = 512;
/// How long a message waits for a control connection to its peer.
const PENDING_PEER_TTL: Duration = Duration::from_mins(1);
const BROWSE_PROTECT_WINDOW: Duration = Duration::from_mins(5);
@@ -338,19 +340,23 @@ pub enum ClientOperation {
PossibleParents(Vec<(String, String, u16)>),
/// The server told us to drop our parent.
ResetDistributed,
/// A parent candidate told us how deep it sits.
/// A parent candidate told us how deep it sits. `link` says which dial
/// to that user is talking.
ParentBranchLevel {
parent: String,
link: u64,
level: i32,
},
/// A parent candidate told us whose branch it is on.
ParentBranchRoot {
parent: String,
link: u64,
root: String,
},
/// A search came down the tree from `parent`.
ParentSearch {
parent: String,
link: u64,
username: String,
token: u32,
query: String,
@@ -358,6 +364,7 @@ pub enum ClientOperation {
/// The link to a parent or candidate is gone.
ParentClosed {
parent: String,
link: u64,
},
}
pub struct ClientContext {
@@ -380,8 +387,10 @@ pub struct ClientContext {
pub shared_directories: Vec<String>,
/// Peer listen addresses learned from GetPeerAddress responses.
peer_addresses: HashMap<String, (String, u32)>,
/// Peer messages waiting for a control connection to that peer.
pending_peer_messages: HashMap<String, Vec<crate::message::Message>>,
/// Peer messages waiting for a control connection to that peer, and
/// since when: a peer that never answers must not keep them forever.
pending_peer_messages:
HashMap<String, (Instant, Vec<crate::message::Message>)>,
/// Uploads we have offered, keyed by our transfer token.
uploads: HashMap<u32, UploadJob>,
active_uploads: HashMap<u32, ActiveUpload>,
@@ -474,6 +483,15 @@ impl ClientContext {
}
impl ClientContext {
/// A context for the client that logs in as `username`.
#[must_use]
pub fn for_user(username: &str) -> Self {
Self {
leaf: distributed::Leaf::new(username),
..Self::new()
}
}
#[must_use]
pub fn new() -> Self {
let actor_system = Arc::new(ActorSystem::new());
@@ -725,7 +743,8 @@ impl ClientContext {
) {
self.pending_peer_messages
.entry(username.to_string())
.or_default()
.or_insert_with(|| (Instant::now(), Vec::new()))
.1
.push(message);
}
@@ -736,9 +755,18 @@ impl ClientContext {
) -> Vec<crate::message::Message> {
self.pending_peer_messages
.remove(username)
.map(|(_, messages)| messages)
.unwrap_or_default()
}
/// Drop peer messages nobody could deliver within
/// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed.
pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) {
self.pending_peer_messages.retain(|_, (since, _)| {
now.duration_since(*since) < PENDING_PEER_TTL
});
}
/// Store a shared-file listing received from browsing `username`.
pub fn store_browse_result(
&mut self,
@@ -865,11 +893,13 @@ impl Client {
listen_port: settings.listen_port,
bound_port: None,
address: settings.server_address,
context: Arc::new(RwLock::new(ClientContext::for_user(
&settings.username,
))),
username: settings.username,
password: settings.password,
version: settings.version,
shared_directories: settings.shared_directories,
context: Arc::new(RwLock::new(ClientContext::new())),
server_handle: None,
session: SessionWatch::default(),
}
+45 -13
View File
@@ -17,9 +17,6 @@ impl Client {
own_username: String,
) {
thread::spawn(move || {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.leaf = super::distributed::Leaf::new(&own_username);
}
let mut last_sweep = Instant::now();
loop {
let next = reader.recv_timeout(CONNECT_SWEEP_INTERVAL);
@@ -27,6 +24,9 @@ impl Client {
last_sweep = Instant::now();
Self::sweep_expired_connects(&client_context);
Self::sweep_stale_offers(&client_context);
if let Ok(mut ctx) = client_context.write_safe() {
ctx.expire_pending_peer_messages(Instant::now());
}
}
let operation = match next {
Ok(operation) => operation,
@@ -716,18 +716,45 @@ impl Client {
);
}
}
ClientOperation::ParentBranchLevel { parent, level } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.leaf.branch_level(&parent, level);
ClientOperation::ParentBranchLevel {
parent,
link,
level,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if let Some(branch) =
ctx.leaf.branch_level(&parent, link, level)
{
super::distributed::announce(
ctx.server_sender.as_ref(),
&branch,
true,
);
}
}
ClientOperation::ParentBranchRoot { parent, root } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.leaf.branch_root(&parent, &root);
ClientOperation::ParentBranchRoot {
parent,
link,
root,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if let Some(branch) =
ctx.leaf.branch_root(&parent, link, &root)
{
super::distributed::announce(
ctx.server_sender.as_ref(),
&branch,
true,
);
}
}
ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
@@ -735,14 +762,17 @@ impl Client {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if let Some(branch) = ctx.leaf.search_from(&parent) {
if let Some(branch) =
ctx.leaf.search_from(&parent, link)
{
super::distributed::announce(
ctx.server_sender.as_ref(),
&branch,
true,
);
}
if ctx.leaf.is_parent(&parent)
if ctx.leaf.is_parent(&parent, link)
&& ctx.leaf.admit_search(Instant::now())
&& let Some(ops) = &ctx.operations
{
let _ = ops.send(ClientOperation::IncomingSearch {
@@ -752,11 +782,11 @@ impl Client {
});
}
}
ClientOperation::ParentClosed { parent } => {
ClientOperation::ParentClosed { parent, link } => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if ctx.leaf.closed(&parent) {
if ctx.leaf.closed(&parent, link) {
super::distributed::announce(
ctx.server_sender.as_ref(),
&ctx.leaf.branch(),
@@ -764,6 +794,8 @@ impl Client {
);
}
}
// Also what a fresh login means: whatever tree we hung
// from belongs to the old session.
ClientOperation::ResetDistributed => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
+15
View File
@@ -821,3 +821,18 @@ fn watched_users_are_listed_in_a_stable_order() {
context.add_watched_user("bob");
assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]);
}
// A reply queued for a searcher the server cannot place waits a minute,
// not forever: every such search would otherwise pin a map entry and a
// protected slot in the peer registry.
#[test]
fn a_peer_message_nobody_could_deliver_expires() {
let mut ctx = ClientContext::for_user("me");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now());
assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL);
assert!(ctx.take_peer_messages("ghost").is_empty(), "expired");
}