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 => {}
+76 -3
View File
@@ -71,6 +71,30 @@ struct ActiveUpload {
bytes_sent: Arc<std::sync::atomic::AtomicU64>,
cancel: Arc<std::sync::atomic::AtomicBool>,
status: crate::types::UploadStatus,
/// When streaming began, so a snapshot can report a transfer rate.
started: Instant,
}
/// Transfer rate of an in-progress upload, in bytes per second. Finished,
/// cancelled and failed uploads report zero, matching how a download reports
/// its speed only while running.
///
/// an average over the whole transfer rather than the rolling window
/// the download path samples. Enough to fill the Speed column; sample a window
/// if the figure ever needs to track sudden stalls.
fn upload_speed(
status: &crate::types::UploadStatus,
bytes_sent: u64,
started: Instant,
) -> f64 {
if !matches!(status, crate::types::UploadStatus::InProgress) {
return 0.0;
}
let elapsed = started.elapsed().as_secs_f64();
if elapsed <= 0.0 {
return 0.0;
}
bytes_sent as f64 / elapsed
}
/// Build a `FileSearchResponse` for `query` against `shares`, or `None` if
@@ -662,6 +686,12 @@ impl Client {
}
}
/// The username we log in as, for attributing our own messages.
#[must_use]
pub fn username(&self) -> &str {
&self.username
}
/// The directories whose files are currently shared with other peers.
#[must_use]
pub fn shared_directories(&self) -> Vec<String> {
@@ -692,13 +722,19 @@ impl Client {
.into_iter()
.map(|token| {
let upload = &ctx.active_uploads[token];
let bytes_sent = upload
.bytes_sent
.load(std::sync::atomic::Ordering::Relaxed);
crate::types::UploadInfo {
username: upload.username.clone(),
filename: upload.filename.clone(),
size: upload.size,
bytes_sent: upload
.bytes_sent
.load(std::sync::atomic::Ordering::Relaxed),
bytes_sent,
speed_bytes_per_sec: upload_speed(
&upload.status,
bytes_sent,
upload.started,
),
status: upload.status.clone(),
}
})
@@ -773,3 +809,40 @@ mod operations;
mod rooms;
mod search;
mod uploads;
#[cfg(test)]
mod tests {
use super::upload_speed;
use crate::types::UploadStatus;
use std::time::{Duration, Instant};
#[test]
fn upload_speed_is_reported_only_while_running() {
let two_seconds_ago = Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("clock supports a 2s offset");
// 1 KiB over ~2s is ~512 B/s.
let rate =
upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago);
assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}");
// A finished upload reports no rate, exactly as a finished download
// does, so the Speed column renders "-" rather than a stale figure.
for status in [
UploadStatus::Completed,
UploadStatus::Cancelled,
UploadStatus::Failed("nope".to_string()),
] {
let rate = upload_speed(&status, 1024, two_seconds_ago);
assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}");
}
// A just-started upload must not divide by a zero elapsed time.
let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now());
assert!(
rate.is_finite() && rate.abs() < f64::EPSILON,
"unexpected rate {rate}"
);
}
}
+1
View File
@@ -32,6 +32,7 @@ impl Client {
bytes_sent: bytes_sent.clone(),
cancel: cancel.clone(),
status: UploadStatus::InProgress,
started: std::time::Instant::now(),
},
);
drop(ctx);
+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));
}
}
}
+3
View File
@@ -271,4 +271,7 @@ pub struct UploadInfo {
pub size: u64,
pub bytes_sent: u64,
pub status: UploadStatus,
/// Transfer rate, so an upload row can fill the same Speed column a
/// download does. Zero unless the upload is in progress.
pub speed_bytes_per_sec: f64,
}