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
+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);