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
+45 -25
View File
@@ -3,6 +3,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::Sender;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use crate::client::{ClientContext, ClientOperation};
@@ -14,6 +15,9 @@ use crate::{DownloadStatus, debug, error, info, trace};
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);
#[derive(Clone)]
struct ConnectionContext {
client_sender: Sender<ClientOperation>,
@@ -31,13 +35,29 @@ fn read_peer_init_message(
stream: &mut TcpStream,
reader: &mut MessageReader,
) -> io::Result<Message> {
loop {
// An untrusted peer gets a bounded handshake. Without this a peer that
// connects and stays silent parks this read forever, and one that hangs up
// spins it (a zero-byte read is EOF, which `read_from_socket` reports as
// success) — either way pinning a thread that owes us a peer init.
stream.set_read_timeout(Some(PEER_INIT_TIMEOUT))?;
let message = loop {
let buffered = reader.buffer_len();
reader.read_from_socket(stream)?;
if reader.buffer_len() == buffered {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"peer closed before sending a peer init",
));
}
if let Ok(Some(msg)) = reader.extract_message() {
return Ok(msg);
break msg;
}
}
};
// Whatever this connection turns into reads on its own terms from here.
stream.set_read_timeout(None)?;
Ok(message)
}
fn parse_peer_init_message(mut message: Message) -> Option<PeerInitData> {
@@ -285,11 +305,15 @@ fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) {
let mut stream = stream;
let mut reader = MessageReader::new();
let Ok(message) = read_peer_init_message(&mut stream, &mut reader) else {
error!(
"[listener:{peer_ip}:{peer_port}] Failed to read peer init message"
);
return;
// A peer that dials and then goes away is routine, not an error.
let message = match read_peer_init_message(&mut stream, &mut reader) {
Ok(message) => message,
Err(e) => {
debug!(
"[listener:{peer_ip}:{peer_port}] no peer init message: {e}"
);
return;
}
};
// A firewalled peer brokered through the server connects back with a
@@ -330,22 +354,15 @@ fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) {
peer, stream, reader, &context, &peer_ip, peer_port,
),
ConnectionType::F => {
thread::spawn(move || {
trace!(
"[listener:{peer_ip}:{peer_port}] handling file connection in thread"
);
handle_file_connection(
peer,
stream,
reader,
init_data.token,
&context,
&peer_ip,
peer_port,
);
});
}
ConnectionType::F => handle_file_connection(
peer,
stream,
reader,
init_data.token,
&context,
&peer_ip,
peer_port,
),
ConnectionType::D => {
debug!(
"[listener:{peer_ip}:{peer_port}] connection type is D, not supported yet, closing connection. "
@@ -384,7 +401,10 @@ impl Listen {
};
let context = context.clone();
handle_incoming_connection(stream, context);
// 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));
}
}
}