fix(client): bound the threads a peer flood can create

One popular search produced a ConnectToPeer for every responder, and
each one cost a 2 MiB wrapper thread, an unbounded 256 KiB actor, a
listener thread per inbound socket, and on failure a 2 MiB thread that
slept for 20 seconds. A container with a pids limit died on one album
search (issues #32, #33).

Four bounds, no behavior change for a quiet session:
- P-type registrations run inline on the ops loop; only F transfers
  keep a worker thread. The dial already ran on the actor thread.
- The broker timeout is a deadline swept by the ops loop instead of a
  sleeping thread per failed dial.
- The registry caps live actors (default 512, Client::set_max_peers).
  At capacity the oldest peer outside the caller's protected set
  (live downloads, uploads, pending serves, queued messages, browses
  in flight) is evicted; if none qualify the registration is refused
  before any thread is spawned.
- Concurrent listener handshakes are bounded by a 128-permit
  semaphore released once the peer-init arrives, so slow handshakes
  cannot multiply threads while transfers stay unthrottled.

A replayed TransferResponse for an InProgress or Completed token is
dropped instead of starting a second transfer thread over the same
.part file.

Fixes #32
Fixes #33
This commit is contained in:
Michel de Graaf
2026-08-13 13:46:18 +02:00
parent fd1d168ca6
commit 9fe1c9984c
9 changed files with 458 additions and 60 deletions
+1
View File
@@ -3,6 +3,7 @@ pub mod logger;
pub mod lock;
pub mod md5;
pub mod path;
pub(crate) mod semaphore;
pub mod zlib;
// Re-export commonly used items
+73
View File
@@ -0,0 +1,73 @@
use std::sync::{Arc, Condvar, Mutex, PoisonError};
pub struct Semaphore {
permits: Mutex<usize>,
available: Condvar,
}
pub struct Permit(Arc<Semaphore>);
impl Semaphore {
#[must_use]
pub const fn new(permits: usize) -> Self {
Self {
permits: Mutex::new(permits),
available: Condvar::new(),
}
}
#[must_use]
pub fn acquire(self: &Arc<Self>) -> Permit {
let mut permits =
self.permits.lock().unwrap_or_else(PoisonError::into_inner);
while *permits == 0 {
permits = self
.available
.wait(permits)
.unwrap_or_else(PoisonError::into_inner);
}
*permits -= 1;
Permit(Arc::clone(self))
}
}
impl Drop for Permit {
fn drop(&mut self) {
let mut permits = self
.0
.permits
.lock()
.unwrap_or_else(PoisonError::into_inner);
*permits += 1;
self.0.available.notify_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn permits_bound_concurrent_holders() {
let semaphore = Arc::new(Semaphore::new(2));
let first = semaphore.acquire();
let _second = semaphore.acquire();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _third = semaphore.acquire();
tx.send(()).unwrap();
});
assert!(
rx.recv_timeout(Duration::from_millis(100)).is_err(),
"a third acquire must block while both permits are held"
);
drop(first);
assert!(
rx.recv_timeout(Duration::from_secs(2)).is_ok(),
"releasing a permit must wake the waiter"
);
}
}