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
+17 -2
View File
@@ -11,6 +11,7 @@ use crate::message::{Message, MessageReader};
use crate::peer::{ConnectionType, DownloadPeer, Peer};
use crate::types::Download;
use crate::utils::lock::RwLockExt;
use crate::utils::semaphore::{Permit, Semaphore};
use crate::{DownloadStatus, debug, error, info, trace};
/// How long to wait before accepting again after a failure.
@@ -25,6 +26,8 @@ const PEER_INIT_MESSAGE_CODE: u8 = 1;
/// How long an accepted peer gets to send its peer-init handshake.
const PEER_INIT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_HANDSHAKES: usize = 128;
#[derive(Clone)]
struct ConnectionContext {
client_sender: Sender<ClientOperation>,
@@ -140,7 +143,13 @@ fn handle_peer_connection(
}
};
if let Some(ref registry) = client_context.peer_registry {
match registry.register_peer(peer.clone(), Some(stream), Some(reader)) {
let protected = client_context.protected_peers();
match registry.register_peer_protected(
peer.clone(),
Some(stream),
Some(reader),
&protected,
) {
Ok(_) => (),
Err(e) => {
error!(
@@ -283,6 +292,7 @@ fn handle_pierce_firewall(
fn handle_incoming_connection(
mut stream: TcpStream,
context: ConnectionContext,
permit: Permit,
) {
let Ok(peer_addr) = stream.peer_addr() else {
error!("[listener] failed to get peer address");
@@ -303,6 +313,7 @@ fn handle_incoming_connection(
return;
}
};
drop(permit);
// A firewalled peer brokered through the server connects back with a
// PierceFirewall (code 0) instead of a PeerInit (code 1).
@@ -406,6 +417,7 @@ impl Listen {
client_context,
own_username,
};
let handshakes = Arc::new(Semaphore::new(MAX_HANDSHAKES));
for stream in listener.incoming() {
let Ok(stream) = stream else {
@@ -421,11 +433,14 @@ impl Listen {
continue;
};
let permit = handshakes.acquire();
let context = context.clone();
// One thread per connection: the peer-init handshake blocks, and a
// peer that is slow to send one must not stop us accepting anybody
// else — a wedged accept loop makes us unreachable to every peer.
thread::spawn(move || handle_incoming_connection(stream, context));
thread::spawn(move || {
handle_incoming_connection(stream, context, permit);
});
}
}
}