fix(peer): serve full browse listings + harden the listener; TUI transfer/UX work

Peer layer:
- Stream peer messages with a partial-write queue instead of write_all, so a
  large SharedFileListResponse over the non-blocking socket is no longer
  truncated (third-party clients like SoulseekQt can now browse our shares).
- Bound the peer-init handshake with a read timeout and handle each accepted
  connection on its own thread, so one stalled peer can't wedge the listener.
- e2e: third-party direct/brokered/large-share browse + stalled-peer coverage.

TUI:
- Uploads share the downloads table columns (real Speed) and the info pane now
  renders details for a selected upload.
- Download progress bar counts only in-progress transfers and hides when idle.
- Always-visible unread badge (private messages + rooms) in the shortcuts bar,
  self-drawn blink so it works under tmux.
- Persist the unread private-message count across restarts.
This commit is contained in:
Michel de Graaf
2026-07-24 21:18:20 +02:00
parent 105c798855
commit be5dc6c064
24 changed files with 1503 additions and 159 deletions
+72 -7
View File
@@ -80,8 +80,17 @@ pub struct PeerActor {
/// Transfer tokens for uploads we are serving to this peer. A TransferResponse
/// for one of these is our upload being accepted, not a download offer.
serving_tokens: std::collections::HashSet<u32>,
/// Bytes queued for the peer that the socket would not take yet. The stream
/// is non-blocking (so socket reads never starve the mailbox), so a message
/// larger than the send buffer — a full share listing is easily megabytes —
/// only partly fits; the rest drains on later ticks.
pending_write: Vec<u8>,
}
/// Cap on queued outbound bytes. A peer that stops reading must not be able to
/// grow this without bound; dropping that connection is the right answer.
const MAX_PENDING_WRITE: usize = 32 * 1024 * 1024;
impl PeerActor {
#[must_use]
pub fn new(
@@ -115,6 +124,7 @@ impl PeerActor {
disconnect_reported: false,
id,
serving_tokens: std::collections::HashSet::new(),
pending_write: Vec::new(),
}
}
@@ -512,10 +522,10 @@ impl PeerActor {
fn send_message(&mut self, message: Message) {
let username = self.peer_username();
let Some(stream) = self.stream.as_mut() else {
if self.stream.is_none() {
error!("Cannot send message: stream is None");
return;
};
}
trace!(
"[peer:{}] ➡ {:?}",
@@ -530,7 +540,51 @@ impl PeerActor {
.map_err(|e| e.to_string())
);
if let Err(e) = stream.write_all(&message.get_buffer()) {
// Queue then drain, so messages always leave in order even when an
// earlier one is still partly buffered.
self.pending_write.extend_from_slice(&message.get_buffer());
self.flush_pending_write();
}
/// Push as much of `pending_write` into the socket as it will take. A
/// non-blocking socket refuses the rest with `WouldBlock`; that is normal
/// backpressure, not a failure, so the remainder stays queued for the next
/// tick. Only a real I/O error disconnects.
fn flush_pending_write(&mut self) {
if self.pending_write.is_empty() {
return;
}
let username = self.peer_username();
let Some(stream) = self.stream.as_mut() else {
return;
};
let mut written = 0;
let outcome = loop {
match stream.write(&self.pending_write[written..]) {
Ok(0) => {
break Err(Error::new(
io::ErrorKind::WriteZero,
"peer stopped accepting data",
));
}
Ok(n) => {
written += n;
if written == self.pending_write.len() {
break Ok(());
}
}
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
break Ok(());
}
Err(e) => break Err(e),
}
};
self.pending_write.drain(..written);
if let Err(e) = outcome {
error!(
"[peer:{}] Error writing message: {}. Disconnecting.",
username, e
@@ -539,12 +593,17 @@ impl PeerActor {
return;
}
if let Err(e) = stream.flush() {
// Backpressure is normal, but what a peer has still not taken after we
// pushed everything we could is a peer that is not reading at all.
if self.pending_write.len() > MAX_PENDING_WRITE {
error!(
"[peer:{}] Error flushing stream: {}. Disconnecting.",
username, e
"[peer:{}] Outbound queue stuck above {} bytes. Disconnecting.",
username, MAX_PENDING_WRITE
);
self.disconnect_with_error(e);
self.disconnect_with_error(Error::new(
io::ErrorKind::WriteZero,
"peer is not draining our outbound queue",
));
}
}
@@ -785,7 +844,13 @@ impl Actor for PeerActor {
}
ConnectionState::Connected => {
if self.stream.is_some() {
// a backlogged message drains one socket buffer
// per 100ms tick. Enough for a share listing; if a future
// message stream needs more throughput, wake the actor as
// soon as the socket is writable instead of on the tick.
self.flush_pending_write();
self.process_read();
self.flush_pending_write();
}
}
ConnectionState::Disconnected => {}