Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
[package]
name = "soulseek-client"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
soulseek-proto = { path = "../soulseek-proto" }
tokio.workspace = true
tokio-util = "0.7"
bytes.workspace = true
thiserror.workspace = true
log.workspace = true
igd-next = "0.15"
[dev-dependencies]
env_logger = "0.11"
@@ -0,0 +1,86 @@
//! Live citizenship test: share our downloaded albums, join the distributed
//! network, and report every search we answer for real peers.
//!
//! cargo run -p soulseek-client --example citizen
use soulseek_client::{Client, Config, Event};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let username = format!("ssplusshare{suffix}");
let music = dirs_music().join("SoulSeek+");
let mut config = Config::default();
config.shared_folders = vec![music.clone()];
config.upload_slots = 4;
config.description = "SoulSeek+ dev share".into();
println!("sharing {}…", music.display());
let (client, mut events) = Client::connect(config).await.expect("connect");
let (dirs, files) = client.share_counts();
println!("indexed {files} files in {dirs} folders");
client
.login(&username, "ssplusdev-throwaway")
.await
.expect("login");
println!("logged in as {username}; joining network, waiting for searches to answer…\n");
let mut answered = 0usize;
let mut uploads = 0usize;
let start = tokio::time::Instant::now();
let deadline = start + std::time::Duration::from_secs(180);
let mut next_tick = start + std::time::Duration::from_secs(20);
loop {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(next_tick) => {
next_tick += std::time::Duration::from_secs(20);
println!(
" … {}s: parent={}, {} searches seen, {answered} answered",
start.elapsed().as_secs(),
client.has_parent(),
client.searches_seen(),
);
if tokio::time::Instant::now() >= deadline { break; }
continue;
}
};
match ev {
Some(Event::RespondedToSearch { username, files }) => {
answered += 1;
if answered <= 25 {
println!(" ✓ answered {username} with {files} file(s)");
}
}
Some(Event::Upload(u)) => {
uploads += 1;
println!(" ⬆ upload to {}: {:?} {}", u.username, u.state, u.filename);
}
Some(Event::Disconnected { reason }) => {
println!("disconnected: {reason}");
break;
}
_ => {}
}
}
println!(
"\nRESULT: {} searches seen, {answered} answered, {uploads} upload events",
client.searches_seen(),
);
client.shutdown();
}
fn dirs_music() -> std::path::PathBuf {
// %USERPROFILE%\Music
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(profile).join("Music")
}
+123
View File
@@ -0,0 +1,123 @@
//! Live-network download smoke test: search, pick the best folder,
//! download its files.
//!
//! cargo run -p soulseek-client --example download -- "search terms" [max_files]
use std::collections::HashMap;
use soulseek_client::{Client, Config, DownloadState, Event};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let query = std::env::args()
.nth(1)
.unwrap_or_else(|| "daft punk discovery".into());
let max_files: usize = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(2);
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let username = format!("ssplusdev{suffix}");
let mut config = Config::default();
config.downloads_dir = std::env::temp_dir().join("ssplus-download-test");
let (client, mut events) = Client::connect(config).await.expect("connect");
client
.login(&username, "ssplusdev-throwaway")
.await
.expect("login");
println!("logged in as {username}");
let token = client.search(&query).expect("search");
println!("searching {query:?}…");
// Collect responses for 12s, pick the best free-slot folder.
let mut best: Option<(u64, soulseek_proto::types::SearchResponse)> = None;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(12);
loop {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(deadline) => break,
};
if let Some(Event::SearchResult { response, .. }) = ev {
if !response.has_free_upload_slot || response.files.len() < 3 {
continue;
}
let score = response.upload_speed as u64;
if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) {
best = Some((score, response));
}
}
}
client.stop_search(token);
let Some((_, pick)) = best else {
println!("RESULT: no suitable candidates");
return;
};
println!(
"picked {} ({} files, speed {})",
pick.username,
pick.files.len(),
pick.upload_speed
);
let mut pending: HashMap<u64, String> = HashMap::new();
for f in pick.files.iter().take(max_files) {
let id = client.download(&pick.username, &f.filename, f.size);
println!(
" queued #{id}: {}",
f.filename.rsplit('\\').next().unwrap()
);
pending.insert(id, f.filename.clone());
}
let overall = tokio::time::Instant::now() + std::time::Duration::from_secs(180);
let mut ok = 0;
while !pending.is_empty() {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(overall) => { println!("TIMEOUT"); break; }
};
match ev {
Some(Event::Download(u)) => match &u.state {
DownloadState::Complete { local_path } => {
println!(" ✔ #{} complete → {local_path}", u.id);
pending.remove(&u.id);
ok += 1;
}
DownloadState::Failed { reason } => {
println!(" ✘ #{} failed: {reason}", u.id);
pending.remove(&u.id);
}
DownloadState::Transferring if u.total > 0 => {
println!(
" … #{} {:.0}% ({}/{} bytes, {} KB/s)",
u.id,
u.received as f64 / u.total as f64 * 100.0,
u.received,
u.total,
u.speed_bps / 1000
);
}
s => println!(" · #{} {:?}", u.id, s),
},
Some(Event::Disconnected { reason }) => {
println!("disconnected: {reason}");
break;
}
Some(_) => {}
None => break,
}
}
println!("RESULT: {ok} downloads completed");
client.shutdown();
}
+78
View File
@@ -0,0 +1,78 @@
//! Smoke test against the live Soulseek network:
//! login with a throwaway account, run one search, print results.
//!
//! cargo run -p soulseek-client --example probe -- "search terms"
use soulseek_client::{Client, Config, Event};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let query = std::env::args()
.nth(1)
.unwrap_or_else(|| "daft punk discovery".into());
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let username = format!("ssplusdev{suffix}");
println!("connecting…");
let (client, mut events) = Client::connect(Config::default()).await.expect("connect");
println!("listener on port {}", client.listen_port());
match client.login(&username, "ssplusdev-throwaway").await {
Ok(greeting) => println!("logged in as {username}: {}", greeting.trim()),
Err(e) => {
eprintln!("login failed: {e}");
return;
}
}
let token = client.search(&query).expect("search");
println!("searching for {query:?} (token {token}) — collecting for 25s…\n");
let mut responses = 0usize;
let mut files = 0usize;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(25);
loop {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(deadline) => break,
};
match ev {
Some(Event::SearchResult { response, .. }) => {
responses += 1;
files += response.files.len();
if responses <= 5 {
let f = &response.files[0];
println!(
" [{}] {} files, e.g. {} ({} MB{}) slots={} speed={}",
response.username,
response.files.len(),
f.filename.rsplit('\\').next().unwrap_or(&f.filename),
f.size / 1_000_000,
f.bitrate()
.map(|b| format!(", {b} kbps"))
.unwrap_or_default(),
response.has_free_upload_slot,
response.upload_speed,
);
}
}
Some(Event::Disconnected { reason }) => {
println!("disconnected: {reason}");
break;
}
Some(_) => {}
None => break,
}
}
println!("\nRESULT: {responses} responses, {files} files");
client.shutdown();
}
@@ -0,0 +1,129 @@
//! Two-instance upload test: instance A shares a known file; instance B
//! downloads it from A. Verifies the upload responder + F-connection path.
//!
//! cargo run -p soulseek-client --example upload_test
use soulseek_client::{Client, Config, DownloadState, Event};
use std::path::PathBuf;
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
// Build a share source with one real audio file.
let src = std::env::temp_dir().join("ssplus-upload-src");
let album = src.join("TestAlbum");
std::fs::create_dir_all(&album).unwrap();
let sample = find_sample().expect("need a downloaded audio file to share");
let shared_file = album.join("sample.flac");
std::fs::copy(&sample, &shared_file).unwrap();
let size = std::fs::metadata(&shared_file).unwrap().len();
let remote_path = "ssplus-upload-src\\TestAlbum\\sample.flac".to_string();
println!("A shares {remote_path} ({size} bytes)");
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let user_a = format!("ssplusA{stamp}");
let user_b = format!("ssplusB{stamp}");
// Instance A: sharer/uploader.
let mut cfg_a = Config::default();
cfg_a.shared_folders = vec![src.clone()];
cfg_a.listen_port = 51300;
let (client_a, mut events_a) = Client::connect(cfg_a).await.expect("A connect");
client_a.login(&user_a, "pw").await.expect("A login");
println!("A logged in as {user_a}");
// Spawn a task to log A's upload events.
tokio::spawn(async move {
while let Some(ev) = events_a.recv().await {
if let Event::Upload(u) = ev {
println!(
" A upload → {}: {:?} {}/{}",
u.username, u.state, u.sent, u.total
);
}
}
});
// Instance B: downloader. Different listen port to coexist on one host.
let mut cfg_b = Config::default();
cfg_b.listen_port = 52300;
cfg_b.downloads_dir = std::env::temp_dir().join("ssplus-upload-dst");
let _ = std::fs::remove_dir_all(&cfg_b.downloads_dir);
let (client_b, mut events_b) = Client::connect(cfg_b).await.expect("B connect");
client_b.login(&user_b, "pw").await.expect("B login");
println!("B logged in as {user_b}");
// Both instances share this host's public IP, so the server-provided
// address would need NAT hairpinning. Seed loopback endpoints so the P
// and F connections go over localhost instead.
client_b.set_peer_address(&user_a, "127.0.0.1:51300".parse().unwrap());
client_a.set_peer_address(&user_b, "127.0.0.1:52300".parse().unwrap());
// Give the server a moment to register both listen ports.
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let id = client_b.download(&user_a, &remote_path, size);
println!("B requested download #{id} from A…\n");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(90);
loop {
let ev = tokio::select! {
ev = events_b.recv() => ev,
_ = tokio::time::sleep_until(deadline) => { println!("TIMEOUT"); break; }
};
match ev {
Some(Event::Download(u)) => match &u.state {
DownloadState::Complete { local_path } => {
let got = std::fs::metadata(local_path).map(|m| m.len()).unwrap_or(0);
println!("\nB COMPLETE: {local_path} ({got}/{size} bytes)");
println!(
"RESULT: {}",
if got == size {
"UPLOAD VERIFIED ✓"
} else {
"size mismatch ✗"
}
);
break;
}
DownloadState::Failed { reason } => {
println!("\nB FAILED: {reason}");
println!("RESULT: upload not verified (likely NAT hairpin on same host)");
break;
}
other => println!(" B: {:?} {}/{}", other, u.received, u.total),
},
Some(_) => {}
None => break,
}
}
client_a.shutdown();
client_b.shutdown();
}
/// Find any downloaded audio file to use as the shared sample.
fn find_sample() -> Option<PathBuf> {
let root = PathBuf::from(std::env::var("USERPROFILE").ok()?)
.join("Music")
.join("SoulSeek+");
fn walk(dir: &std::path::Path) -> Option<PathBuf> {
for e in std::fs::read_dir(dir).ok()?.flatten() {
let p = e.path();
if p.is_dir() {
if let Some(f) = walk(&p) {
return Some(f);
}
} else if p.extension().and_then(|x| x.to_str()) == Some("flac") {
return Some(p);
}
}
None
}
walk(&root)
}
+668
View File
@@ -0,0 +1,668 @@
//! Connection plumbing: server socket IO, peer listener, peer message
//! loops, indirect (PierceFirewall) connections, and transfer-connection
//! establishment in all four direct/indirect × inbound/outbound flavors.
use bytes::Bytes;
use std::net::SocketAddrV4;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use soulseek_proto::{peer, server, MAX_MESSAGE_SIZE};
use crate::{ClientError, Event, Inner, PeerHandle, Result, TransferAck};
pub(crate) const PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
pub(crate) const INDIRECT_TIMEOUT: Duration = Duration::from_secs(15);
/// Idle cutoff for peer message connections.
const PEER_READ_TIMEOUT: Duration = Duration::from_secs(60);
const SERVER_PING_INTERVAL: Duration = Duration::from_secs(300);
/// Read one length-prefixed frame; returns the frame body (code + payload).
pub(crate) async fn read_frame<R: AsyncReadExt + Unpin>(
stream: &mut R,
) -> std::io::Result<Option<Bytes>> {
let mut len_buf = [0u8; 4];
match stream.read_exact(&mut len_buf).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_le_bytes(len_buf) as usize;
if len == 0 || len > MAX_MESSAGE_SIZE {
return Err(std::io::Error::other(format!("bad frame length {len}")));
}
let mut body = vec![0u8; len];
stream.read_exact(&mut body).await?;
Ok(Some(Bytes::from(body)))
}
pub(crate) async fn bind_listener(preferred_port: u16) -> std::io::Result<TcpListener> {
// Walk forward a few ports if the preferred one is taken.
for offset in 0..10 {
match TcpListener::bind(("0.0.0.0", preferred_port + offset)).await {
Ok(l) => return Ok(l),
Err(_) if offset < 9 => continue,
Err(e) => return Err(e),
}
}
unreachable!()
}
// ---------------------------------------------------------------- server IO
pub(crate) fn spawn_server_io(
stream: TcpStream,
mut outbound: mpsc::UnboundedReceiver<Vec<u8>>,
mut shutdown: mpsc::UnboundedReceiver<()>,
inner: Arc<Inner>,
) {
tokio::spawn(async move {
let (mut rd, mut wr) = stream.into_split();
let writer = tokio::spawn(async move {
while let Some(frame) = outbound.recv().await {
if wr.write_all(&frame).await.is_err() {
break;
}
}
});
let ping_inner = inner.clone();
let ping = tokio::spawn(async move {
loop {
tokio::time::sleep(SERVER_PING_INTERVAL).await;
if ping_inner.send_server(server::ping()).is_err() {
break;
}
}
});
let reason = loop {
let frame = tokio::select! {
f = read_frame(&mut rd) => f,
_ = shutdown.recv() => break "shutdown requested".to_string(),
};
match frame {
Ok(Some(body)) => handle_server_frame(&inner, body),
Ok(None) => break "server closed the connection".to_string(),
Err(e) => break format!("server read error: {e}"),
}
};
writer.abort();
ping.abort();
let _ = inner.event_tx.send(Event::Disconnected { reason });
});
}
fn handle_server_frame(inner: &Arc<Inner>, mut body: Bytes) {
use bytes::Buf;
if body.len() < 4 {
return;
}
let code = body.get_u32_le();
let event = match server::decode(code, body) {
Ok(ev) => ev,
Err(e) => {
log::warn!("failed to decode server message {code}: {e}");
return;
}
};
match event {
server::ServerEvent::LoginOk {
greeting, own_ip, ..
} => {
if let Some(tx) = inner.pending_login.lock().unwrap().take() {
let _ = tx.send(Ok(greeting.clone()));
}
let _ = inner.event_tx.send(Event::LoggedIn { greeting, own_ip });
}
server::ServerEvent::LoginFailed { reason } => {
if let Some(tx) = inner.pending_login.lock().unwrap().take() {
let _ = tx.send(Err(reason.clone()));
}
let _ = inner.event_tx.send(Event::LoginFailed { reason });
}
server::ServerEvent::UserAddress { username, addr } => {
if addr.ip().is_unspecified() || addr.port() == 0 {
// Offline; let the waiters time out.
inner.pending_addresses.lock().unwrap().remove(&username);
return;
}
inner
.address_cache
.lock()
.unwrap()
.insert(username.clone(), addr);
if let Some(waiters) = inner.pending_addresses.lock().unwrap().remove(&username) {
for w in waiters {
let _ = w.send(addr);
}
}
}
server::ServerEvent::ConnectToPeer {
username,
conn_type,
addr,
token,
..
} => {
// A peer that can't reach us directly asked the server to relay:
// we connect out and pierce with their token.
let inner = inner.clone();
tokio::spawn(async move {
let result = match conn_type.as_str() {
peer::CONN_TYPE_PEER => {
pierce_message_connection(inner, addr, token, username.clone()).await
}
peer::CONN_TYPE_TRANSFER => {
pierce_transfer_connection(inner, addr, token, username.clone()).await
}
_ => Ok(()), // "D" (distributed) lands in M5.
};
if let Err(e) = result {
log::debug!("pierce ({conn_type}) to {username} at {addr} failed: {e}");
}
});
}
server::ServerEvent::ExcludedSearchPhrases(phrases) => {
*inner.excluded_phrases.write().unwrap() = phrases.clone();
let _ = inner.event_tx.send(Event::ExcludedSearchPhrases(phrases));
}
server::ServerEvent::NetInfo(parents) => {
crate::distributed::on_net_info(inner.clone(), parents);
}
server::ServerEvent::ServerSearch {
username,
token,
query,
} => {
crate::responder::respond_to_search(inner.clone(), username, token, query);
}
server::ServerEvent::Embedded(bytes) => {
// Server-wrapped distributed search.
if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest {
username,
token,
query,
}) = soulseek_proto::dist::decode(bytes)
{
crate::responder::respond_to_search(inner.clone(), username, token, query);
}
}
server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {}
}
}
// ---------------------------------------------------------------- listener
pub(crate) fn spawn_listener(
listener: TcpListener,
inner: Arc<Inner>,
mut shutdown: mpsc::UnboundedReceiver<()>,
) {
tokio::spawn(async move {
loop {
tokio::select! {
// On shutdown, break so `listener` drops and its port is freed
// — otherwise every reconnect leaks a bound port (WSAEADDRINUSE).
_ = shutdown.recv() => {
log::debug!("listener shutting down, releasing port");
break;
}
res = listener.accept() => match res {
Ok((stream, addr)) => {
let inner = inner.clone();
tokio::spawn(async move {
if let Err(e) = handle_incoming(stream, inner).await {
log::debug!("peer {addr} dropped: {e}");
}
});
}
Err(e) => {
log::warn!("listener accept failed: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
});
}
/// Incoming connection: first frame identifies the peer and connection type.
async fn handle_incoming(mut stream: TcpStream, inner: Arc<Inner>) -> std::io::Result<()> {
stream.set_nodelay(true).ok();
let first = tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream))
.await
.map_err(|_| std::io::Error::other("init timeout"))??;
let Some(first) = first else { return Ok(()) };
match peer::decode_init(first) {
Ok(peer::InitMessage::PeerInit {
conn_type,
username,
..
}) => {
match conn_type.as_str() {
peer::CONN_TYPE_PEER => run_peer_connection(stream, inner, username).await,
peer::CONN_TYPE_TRANSFER => {
// Inbound transfer connection: the peer identifies the
// transfer with 4 raw token bytes next.
let mut tok = [0u8; 4];
tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok))
.await
.map_err(|_| std::io::Error::other("token timeout"))??;
let token = u32::from_le_bytes(tok);
deliver_transfer_connection(&inner, &username, token, stream);
Ok(())
}
peer::CONN_TYPE_DISTRIBUTED => {
// A parent connected to us directly; run the child loop.
run_incoming_distributed(stream, inner).await
}
_ => Ok(()),
}
}
Ok(peer::InitMessage::PierceFirewall { token }) => {
// Answer to one of our indirect solicitations (P or F).
let waiter = inner.pending_pierce.lock().unwrap().remove(&token);
if let Some(tx) = waiter {
let _ = tx.send(stream);
}
Ok(())
}
Err(_) => Ok(()),
}
}
/// A distributed parent connected to us directly. Read its relayed
/// searches and answer them (same logic as an outbound parent).
async fn run_incoming_distributed(mut stream: TcpStream, inner: Arc<Inner>) -> std::io::Result<()> {
inner
.has_parent
.store(true, std::sync::atomic::Ordering::Relaxed);
let result = async {
loop {
let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream)).await
{
Ok(f) => f?,
Err(_) => return Ok(()),
};
let Some(frame) = frame else { return Ok(()) };
if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest {
username,
token,
query,
}) = soulseek_proto::dist::decode(frame)
{
crate::responder::respond_to_search(inner.clone(), username, token, query);
}
}
}
.await;
inner
.has_parent
.store(false, std::sync::atomic::Ordering::Relaxed);
result
}
/// Hand an established inbound transfer connection to the download that
/// expects it.
fn deliver_transfer_connection(inner: &Arc<Inner>, username: &str, token: u32, stream: TcpStream) {
let expected = inner
.expected_transfers
.lock()
.unwrap()
.remove(&(username.to_string(), token));
match expected {
Some(tx) => {
let _ = tx.send(stream);
}
None => {
log::debug!("unexpected transfer connection from {username} token {token}");
}
}
}
// ------------------------------------------------- indirect (pierce) roles
/// Server told us a peer wants a message connection: connect + pierce, then
/// treat like any peer message connection.
async fn pierce_message_connection(
inner: Arc<Inner>,
addr: SocketAddrV4,
token: u32,
username: String,
) -> std::io::Result<()> {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
stream.write_all(&peer::pierce_firewall(token)).await?;
run_peer_connection(stream, inner, username).await
}
/// Server told us a peer wants a transfer connection with us: connect,
/// pierce, read the 4-byte remote token, hand off to the waiting download.
async fn pierce_transfer_connection(
inner: Arc<Inner>,
addr: SocketAddrV4,
token: u32,
username: String,
) -> std::io::Result<()> {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
stream.write_all(&peer::pierce_firewall(token)).await?;
let mut tok = [0u8; 4];
tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok))
.await
.map_err(|_| std::io::Error::other("token timeout"))??;
let remote_token = u32::from_le_bytes(tok);
deliver_transfer_connection(&inner, &username, remote_token, stream);
Ok(())
}
// -------------------------------------------------- peer message connection
/// Run a peer message connection: writer drains a queue, reader routes
/// messages. Registers in the peers map for reuse; deregisters on exit.
async fn run_peer_connection(
stream: TcpStream,
inner: Arc<Inner>,
username: String,
) -> std::io::Result<()> {
let (mut rd, mut wr) = stream.into_split();
let (write_tx, mut write_rx) = mpsc::unbounded_channel::<Vec<u8>>();
inner.peers.lock().unwrap().insert(
username.clone(),
PeerHandle {
write_tx: write_tx.clone(),
},
);
let writer = tokio::spawn(async move {
while let Some(frame) = write_rx.recv().await {
if wr.write_all(&frame).await.is_err() {
break;
}
}
});
let result = peer_message_loop(&mut rd, &inner, &username).await;
// Only deregister if the map still points at this connection.
{
let mut peers = inner.peers.lock().unwrap();
if let Some(handle) = peers.get(&username) {
if handle.write_tx.same_channel(&write_tx) {
peers.remove(&username);
}
}
}
writer.abort();
result
}
async fn peer_message_loop(
rd: &mut tokio::net::tcp::OwnedReadHalf,
inner: &Arc<Inner>,
username: &str,
) -> std::io::Result<()> {
loop {
let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(rd)).await {
Ok(f) => f?,
Err(_) => return Ok(()), // idle
};
let Some(mut body) = frame else { return Ok(()) };
use bytes::Buf;
if body.len() < 4 {
continue;
}
let code = body.get_u32_le();
match peer::decode(code, body) {
Ok(ev) => route_peer_event(inner, username, ev),
Err(e) => log::debug!("bad peer message {code} from {username}: {e}"),
}
}
}
fn route_peer_event(inner: &Arc<Inner>, username: &str, ev: peer::PeerEvent) {
use peer::PeerEvent::*;
match ev {
SearchResponse(resp) => {
let wanted = inner.active_searches.lock().unwrap().contains(&resp.token);
if wanted && !resp.files.is_empty() {
let _ = inner.event_tx.send(Event::SearchResult {
token: resp.token,
response: resp,
});
}
}
TransferResponse {
token,
allowed,
size,
message,
} => {
let waiter = inner
.transfer_waits
.lock()
.unwrap()
.remove(&(username.to_string(), token));
if let Some(tx) = waiter {
let _ = tx.send(TransferAck {
allowed,
size,
message,
});
}
}
TransferRequest {
direction,
token,
filename,
size,
} => {
if direction == peer::DIRECTION_UPLOAD {
let waiter = inner
.upload_offers
.lock()
.unwrap()
.remove(&(username.to_string(), filename.clone()));
match waiter {
Some(tx) => {
let _ = tx.send(Ok((token, size)));
}
None => log::debug!("unsolicited upload offer from {username}: {filename}"),
}
} else {
// Their request to download one of our files → we upload.
crate::upload::on_transfer_request(
inner.clone(),
username.to_string(),
token,
filename,
);
}
}
QueueDownload { filename } => {
crate::upload::on_queue_download(inner.clone(), username.to_string(), filename);
}
PlaceInQueueRequest { filename } => {
crate::upload::on_place_query(inner, username, &filename);
}
BrowseRequest => {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
crate::responder::respond_browse(inner, h);
}
}
UserInfoRequest => {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
crate::responder::respond_user_info(inner, h);
}
}
FolderContentsRequest { token, folder } => {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
crate::responder::respond_folder_contents(inner, h, token, &folder);
}
}
UploadDenied { filename, reason } => {
let waiter = inner
.upload_offers
.lock()
.unwrap()
.remove(&(username.to_string(), filename));
if let Some(tx) = waiter {
let _ = tx.send(Err(reason));
}
}
UploadFailed { filename } => {
let waiter = inner
.upload_offers
.lock()
.unwrap()
.remove(&(username.to_string(), filename));
if let Some(tx) = waiter {
let _ = tx.send(Err("remote upload failed".into()));
}
}
PlaceInQueueResponse { filename, place } => {
crate::transfer::update_queue_place(inner, username, &filename, place);
}
Unhandled { code } => log::trace!("unhandled peer message {code} from {username}"),
}
}
// ----------------------------------------------- outbound peer connections
/// Resolve a user's endpoint via the server (with cache).
pub(crate) async fn get_peer_address(inner: &Arc<Inner>, username: &str) -> Result<SocketAddrV4> {
if let Some(addr) = inner.address_cache.lock().unwrap().get(username) {
return Ok(*addr);
}
let (tx, rx) = oneshot::channel();
inner
.pending_addresses
.lock()
.unwrap()
.entry(username.to_string())
.or_default()
.push(tx);
inner.send_server(server::get_peer_address(username))?;
tokio::time::timeout(Duration::from_secs(10), rx)
.await
.map_err(|_| ClientError::UserOffline(username.to_string()))?
.map_err(|_| ClientError::UserOffline(username.to_string()))
}
/// Get (or establish) a message connection to a user. Tries direct first,
/// then falls back to a server-relayed indirect connection.
pub(crate) async fn get_or_connect_peer(inner: &Arc<Inner>, username: &str) -> Result<PeerHandle> {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
return Ok(h.clone());
}
let addr = get_peer_address(inner, username).await?;
// Direct attempt.
let direct = async {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
let init = peer::peer_init(
&inner.own_username(),
peer::CONN_TYPE_PEER,
inner.next_token(),
);
stream.write_all(&init).await?;
Ok::<TcpStream, std::io::Error>(stream)
};
let stream = match direct.await {
Ok(s) => s,
Err(e) => {
log::debug!("direct connect to {username} failed ({e}); trying indirect");
indirect_connect(inner, username, peer::CONN_TYPE_PEER).await?
}
};
let inner2 = inner.clone();
let username2 = username.to_string();
tokio::spawn(async move {
let _ = run_peer_connection(stream, inner2, username2).await;
});
// The handle is registered synchronously at the top of
// run_peer_connection, but the spawn may not have run yet — poll briefly.
for _ in 0..50 {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
return Ok(h.clone());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(ClientError::PeerUnreachable(username.to_string()))
}
/// Ask the server to have `username` connect to us; await the pierce.
pub(crate) async fn indirect_connect(
inner: &Arc<Inner>,
username: &str,
conn_type: &str,
) -> Result<TcpStream> {
let token = inner.next_token();
let (tx, rx) = oneshot::channel();
inner.pending_pierce.lock().unwrap().insert(token, tx);
inner.send_server(server::connect_to_peer(token, username, conn_type))?;
let out = tokio::time::timeout(INDIRECT_TIMEOUT, rx).await;
inner.pending_pierce.lock().unwrap().remove(&token);
out.map_err(|_| ClientError::PeerUnreachable(username.to_string()))?
.map_err(|_| ClientError::PeerUnreachable(username.to_string()))
}
/// Establish an outbound transfer ("F") connection and negotiate the token.
pub(crate) async fn connect_transfer(
inner: &Arc<Inner>,
username: &str,
token: u32,
) -> Result<TcpStream> {
let addr = get_peer_address(inner, username).await?;
let direct = async {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_TRANSFER, token);
stream.write_all(&init).await?;
Ok::<TcpStream, std::io::Error>(stream)
};
let mut stream = match direct.await {
Ok(s) => s,
Err(e) => {
log::debug!("direct F connect to {username} failed ({e}); trying indirect");
indirect_connect(inner, username, peer::CONN_TYPE_TRANSFER).await?
}
};
// Both flavors: identify the transfer with 4 raw token bytes.
stream
.write_all(&token.to_le_bytes())
.await
.map_err(|_| ClientError::PeerUnreachable(username.to_string()))?;
Ok(stream)
}
+106
View File
@@ -0,0 +1,106 @@
//! Distributed network: we join as a leaf child. The server sends parent
//! candidates via NetInfo; we connect to one (type "D"), complete the
//! branch handshake, and receive relayed `DistributedSearchRequest`
//! messages, which we answer from our shares.
//!
//! We do not accept children (leaf only) — that keeps the implementation
//! tractable while still making us a search-answering participant.
use std::net::SocketAddrV4;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use soulseek_proto::{dist, peer, server};
use crate::{conn, responder, Inner};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
const READ_TIMEOUT: Duration = Duration::from_secs(120);
/// Try each parent candidate until one connects; on success, run the
/// distributed read loop until it drops, then allow re-solicitation.
pub(crate) fn on_net_info(inner: Arc<Inner>, parents: Vec<(String, SocketAddrV4)>) {
if inner.has_parent.load(Ordering::Relaxed) || parents.is_empty() {
return;
}
tokio::spawn(async move {
for (username, addr) in parents {
if inner.has_parent.load(Ordering::Relaxed) {
return;
}
match connect_parent(&inner, &username, addr).await {
Ok(stream) => {
inner.has_parent.store(true, Ordering::Relaxed);
log::info!("distributed parent: {username} ({addr})");
let _ = run_parent(&inner, stream).await;
inner.has_parent.store(false, Ordering::Relaxed);
// Ask the server for fresh parents next time.
let _ = inner.send_server(server::have_no_parents(true));
return;
}
Err(e) => log::debug!("parent {username} ({addr}) failed: {e}"),
}
}
});
}
async fn connect_parent(
inner: &Arc<Inner>,
_username: &str,
addr: SocketAddrV4,
) -> std::io::Result<TcpStream> {
let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
// Identify as a distributed peer.
let token = inner.next_token();
let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_DISTRIBUTED, token);
stream.write_all(&init).await?;
// Announce ourselves as a leaf: branch level 0, root = us.
stream.write_all(&dist::branch_level(0)).await?;
stream
.write_all(&dist::branch_root(&inner.own_username()))
.await?;
Ok(stream)
}
async fn run_parent(inner: &Arc<Inner>, mut stream: TcpStream) -> std::io::Result<()> {
loop {
let frame = match tokio::time::timeout(READ_TIMEOUT, conn::read_frame(&mut stream)).await {
Ok(f) => f?,
Err(_) => {
// Keepalive ping; if it fails the connection is dead.
if stream.write_all(&dist::ping()).await.is_err() {
return Ok(());
}
continue;
}
};
let Some(frame) = frame else { return Ok(()) };
match dist::decode(frame) {
Ok(dist::DistributedEvent::SearchRequest {
username,
token,
query,
}) => {
responder::respond_to_search(inner.clone(), username, token, query);
}
Ok(dist::DistributedEvent::BranchLevel(level)) => {
// Report our level (parent + 1) to the server.
let _ = inner.send_server(server::branch_level(level + 1));
}
Ok(dist::DistributedEvent::BranchRoot(root)) => {
let _ = inner.send_server(server::branch_root(&root));
}
Ok(_) => {}
Err(e) => log::trace!("bad distributed message: {e}"),
}
}
}
+410
View File
@@ -0,0 +1,410 @@
//! Soulseek network client: server connection, peer connections, search,
//! downloads.
//!
//! Behavioral reference: Soulseek.NET `SoulseekClient.cs` and `src/Network/**`.
//!
//! M1: connect + login, peer listener, indirect (PierceFirewall)
//! connections both directions, live search.
//! M2: downloads — TransferRequest/TransferResponse dance, F-type transfer
//! connections (both directions), resume via offset, download manager.
mod conn;
mod distributed;
mod responder;
mod share;
mod transfer;
mod upload;
mod upnp;
pub use share::ShareIndex;
pub use transfer::{DownloadState, DownloadUpdate};
pub use upload::{UploadState, UploadUpdate};
use std::collections::{HashMap, HashSet, VecDeque};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use soulseek_proto::{server, types::SearchResponse};
pub const DEFAULT_SERVER: (&str, u16) = ("server.slsknet.org", 2242);
pub const DEFAULT_LISTEN_PORT: u16 = 50300;
#[derive(Debug, Clone)]
pub struct Config {
pub server_host: String,
pub server_port: u16,
pub listen_port: u16,
pub downloads_dir: PathBuf,
/// Folders we share with the network.
pub shared_folders: Vec<PathBuf>,
/// Concurrent upload slots offered to peers.
pub upload_slots: u32,
/// User-info card description.
pub description: String,
}
impl Default for Config {
fn default() -> Self {
Config {
server_host: DEFAULT_SERVER.0.into(),
server_port: DEFAULT_SERVER.1,
listen_port: DEFAULT_LISTEN_PORT,
downloads_dir: PathBuf::from("downloads"),
shared_folders: Vec::new(),
upload_slots: 2,
description: "SoulStream user".into(),
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
LoggedIn {
greeting: String,
own_ip: Ipv4Addr,
},
LoginFailed {
reason: String,
},
Disconnected {
reason: String,
},
SearchResult {
token: u32,
response: SearchResponse,
},
ExcludedSearchPhrases(Vec<String>),
Download(DownloadUpdate),
Upload(UploadUpdate),
/// We answered a peer's search with N files (for activity display).
RespondedToSearch {
username: String,
files: usize,
},
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("protocol: {0}")]
Proto(#[from] soulseek_proto::ProtoError),
#[error("not connected to server")]
NotConnected,
#[error("login failed: {0}")]
LoginFailed(String),
#[error("user {0} is offline or unknown")]
UserOffline(String),
#[error("could not reach peer {0}")]
PeerUnreachable(String),
#[error("transfer rejected: {0}")]
TransferRejected(String),
#[error("timed out")]
Timeout,
}
pub type Result<T> = std::result::Result<T, ClientError>;
/// Write handle for an established peer message ("P") connection.
#[derive(Clone)]
pub(crate) struct PeerHandle {
pub write_tx: mpsc::UnboundedSender<Vec<u8>>,
}
/// Peer's answer to our TransferRequest(download).
#[derive(Debug, Clone)]
pub(crate) struct TransferAck {
pub allowed: bool,
pub size: u64,
pub message: String,
}
/// Peer's TransferRequest(upload) once we reach their queue front —
/// or the denial that arrived instead.
pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size)
pub(crate) struct Inner {
pub server_tx: mpsc::UnboundedSender<Vec<u8>>,
pub event_tx: mpsc::UnboundedSender<Event>,
pub username: Mutex<String>,
pub active_searches: Mutex<HashSet<u32>>,
pub pending_login: Mutex<Option<oneshot::Sender<std::result::Result<String, String>>>>,
pub token_counter: AtomicU32,
pub listen_port: u16,
pub downloads_dir: PathBuf,
/// GetPeerAddress requests awaiting a server answer, by username.
pub pending_addresses: Mutex<HashMap<String, Vec<oneshot::Sender<SocketAddrV4>>>>,
/// Last known endpoint per user.
pub address_cache: Mutex<HashMap<String, SocketAddrV4>>,
/// Our indirect-connection solicitations: token → deliver the pierced stream.
pub pending_pierce: Mutex<HashMap<u32, oneshot::Sender<TcpStream>>>,
/// Inbound transfer connections we expect: (username, remote token) → stream.
pub expected_transfers: Mutex<HashMap<(String, u32), oneshot::Sender<TcpStream>>>,
/// Live peer message connections.
pub peers: Mutex<HashMap<String, PeerHandle>>,
/// TransferResponse waits: (username, our token).
pub transfer_waits: Mutex<HashMap<(String, u32), oneshot::Sender<TransferAck>>>,
/// TransferRequest(upload) waits: (username, remote filename).
pub upload_offers: Mutex<HashMap<(String, String), oneshot::Sender<UploadOffer>>>,
/// Active downloads by (username, remote filename) → (id, cancel).
pub downloads_by_file: Mutex<HashMap<(String, String), (u64, CancellationToken)>>,
pub download_counter: AtomicU64,
// ---- M5: sharing / uploads / distributed ----
/// Files we share with the network.
pub shares: Arc<ShareIndex>,
/// Excluded phrases from the server; outgoing search results filter these.
pub excluded_phrases: RwLock<Vec<String>>,
/// Our user-info description.
pub description: String,
/// Total upload slots and how many are in use.
pub upload_slots: u32,
pub uploads_active: AtomicU32,
/// Pending upload queue (username, remote path), FIFO.
pub upload_queue: Mutex<VecDeque<(String, String)>>,
/// Rolling average upload speed reported to peers (bytes/sec).
pub upload_speed: AtomicU32,
pub upload_counter: AtomicU64,
/// Whether we currently have a distributed parent.
pub has_parent: AtomicBool,
/// De-dupe relayed searches so we don't answer the same token twice.
pub answered_searches: Mutex<HashSet<u32>>,
/// Count of relayed searches we've seen (instrumentation).
pub searches_seen: AtomicU64,
}
impl Inner {
pub fn next_token(&self) -> u32 {
self.token_counter.fetch_add(1, Ordering::Relaxed)
}
pub fn own_username(&self) -> String {
self.username.lock().unwrap().clone()
}
pub fn send_server(&self, frame: Vec<u8>) -> Result<()> {
self.server_tx
.send(frame)
.map_err(|_| ClientError::NotConnected)
}
pub fn excluded(&self) -> Vec<String> {
self.excluded_phrases.read().unwrap().clone()
}
pub fn has_free_upload_slot(&self) -> bool {
self.uploads_active.load(Ordering::Relaxed) < self.upload_slots
}
}
/// Handle to a running client. Dropping it does not tear down the
/// connection; call [`Client::shutdown`].
pub struct Client {
inner: Arc<Inner>,
shutdown_tx: mpsc::UnboundedSender<()>,
listener_shutdown_tx: mpsc::UnboundedSender<()>,
}
impl Client {
/// Connect the server socket and start the peer listener.
/// Events (search results, download progress) arrive on the receiver.
pub async fn connect(config: Config) -> Result<(Client, mpsc::UnboundedReceiver<Event>)> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (server_tx, server_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel::<()>();
let (listener_shutdown_tx, listener_shutdown_rx) = mpsc::unbounded_channel::<()>();
let stream = TcpStream::connect((config.server_host.as_str(), config.server_port)).await?;
stream.set_nodelay(true).ok();
// Bind the listener before login so SetListenPort is accurate.
let listener = conn::bind_listener(config.listen_port).await?;
let listen_port = listener.local_addr()?.port();
// Seed tokens off the clock so restarts don't reuse recent tokens.
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(1);
// Build the share index up front (blocking walk on a worker thread).
let shares = Arc::new(ShareIndex::new());
if !config.shared_folders.is_empty() {
let shares2 = shares.clone();
let folders = config.shared_folders.clone();
let (dirs, files) = tokio::task::spawn_blocking(move || shares2.rebuild(&folders))
.await
.unwrap_or((0, 0));
log::info!("share index: {files} files in {dirs} folders");
}
let inner = Arc::new(Inner {
server_tx,
event_tx,
username: Mutex::new(String::new()),
active_searches: Mutex::new(HashSet::new()),
pending_login: Mutex::new(None),
token_counter: AtomicU32::new(seed),
listen_port,
downloads_dir: config.downloads_dir.clone(),
pending_addresses: Mutex::new(HashMap::new()),
address_cache: Mutex::new(HashMap::new()),
pending_pierce: Mutex::new(HashMap::new()),
expected_transfers: Mutex::new(HashMap::new()),
peers: Mutex::new(HashMap::new()),
transfer_waits: Mutex::new(HashMap::new()),
upload_offers: Mutex::new(HashMap::new()),
downloads_by_file: Mutex::new(HashMap::new()),
download_counter: AtomicU64::new(1),
shares,
excluded_phrases: RwLock::new(Vec::new()),
description: config.description.clone(),
upload_slots: config.upload_slots,
uploads_active: AtomicU32::new(0),
upload_queue: Mutex::new(VecDeque::new()),
upload_speed: AtomicU32::new(0),
upload_counter: AtomicU64::new(1),
has_parent: AtomicBool::new(false),
answered_searches: Mutex::new(HashSet::new()),
searches_seen: AtomicU64::new(0),
});
conn::spawn_server_io(stream, server_rx, shutdown_rx, inner.clone());
conn::spawn_listener(listener, inner.clone(), listener_shutdown_rx);
// Best-effort UPnP mapping for direct connectivity (non-fatal).
tokio::spawn(upnp::try_map_port(listen_port));
Ok((
Client {
inner,
shutdown_tx,
listener_shutdown_tx,
},
event_rx,
))
}
/// Log in; resolves when the server answers.
pub async fn login(&self, username: &str, password: &str) -> Result<String> {
let (tx, rx) = oneshot::channel();
*self.inner.pending_login.lock().unwrap() = Some(tx);
*self.inner.username.lock().unwrap() = username.to_string();
self.inner.send_server(server::login(username, password))?;
let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::NotConnected)?;
match outcome {
Ok(greeting) => {
// Post-login bookkeeping the server expects from clients.
self.inner
.send_server(server::set_listen_port(self.inner.listen_port as u32))?;
let (dirs, files) = self.inner.shares.counts();
self.inner
.send_server(server::shared_folders_and_files(dirs, files))?;
// Join the distributed network as a child so we receive
// relayed searches to answer.
self.inner.send_server(server::have_no_parents(true))?;
self.inner
.send_server(server::branch_root(&self.inner.own_username()))?;
self.inner.send_server(server::branch_level(0))?;
self.inner.send_server(server::accept_children(false))?;
Ok(greeting)
}
Err(reason) => Err(ClientError::LoginFailed(reason)),
}
}
/// Re-index the shared folders and report the new counts to the server.
pub async fn update_shares(&self, folders: Vec<PathBuf>) -> (u32, u32) {
let shares = self.inner.shares.clone();
let (dirs, files) = tokio::task::spawn_blocking(move || shares.rebuild(&folders))
.await
.unwrap_or((0, 0));
let _ = self
.inner
.send_server(server::shared_folders_and_files(dirs, files));
(dirs, files)
}
/// Current (folders, files) share counts.
pub fn share_counts(&self) -> (u32, u32) {
self.inner.shares.counts()
}
/// How many relayed searches we've received (whether or not matched).
pub fn searches_seen(&self) -> u64 {
self.inner.searches_seen.load(Ordering::Relaxed)
}
/// Seed a peer's endpoint, bypassing server address resolution. Useful
/// for LAN peers and for tests where two instances share one public IP.
pub fn set_peer_address(&self, username: &str, addr: SocketAddrV4) {
self.inner
.address_cache
.lock()
.unwrap()
.insert(username.to_string(), addr);
}
/// Whether we currently hold a distributed parent connection.
pub fn has_parent(&self) -> bool {
self.inner.has_parent.load(Ordering::Relaxed)
}
/// Start a network-wide file search. Results stream out as
/// [`Event::SearchResult`] carrying this token.
pub fn search(&self, query: &str) -> Result<u32> {
let token = self.inner.next_token();
self.inner.active_searches.lock().unwrap().insert(token);
self.inner.send_server(server::file_search(token, query))?;
Ok(token)
}
/// Stop routing results for a search token.
pub fn stop_search(&self, token: u32) {
self.inner.active_searches.lock().unwrap().remove(&token);
}
/// Start downloading `filename` (full remote path) from `username`.
/// Progress streams out as [`Event::Download`]. Returns the download id.
pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, None)
}
/// Like [`download`](Self::download) but writing to an explicit local
/// path (used for stream-to-cache).
pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, Some(target))
}
/// Cancel an active download by id.
pub fn cancel_download(&self, id: u64) {
let map = self.inner.downloads_by_file.lock().unwrap();
for (did, cancel) in map.values() {
if *did == id {
cancel.cancel();
break;
}
}
}
pub fn listen_port(&self) -> u16 {
self.inner.listen_port
}
pub fn shutdown(&self) {
let _ = self.shutdown_tx.send(());
let _ = self.listener_shutdown_tx.send(());
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Responder: answers other peers' requests — searches, browse, user-info,
//! folder contents. Search replies open a fresh P connection to the
//! searcher; the rest reply on the connection the request arrived on.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use soulseek_proto::peer;
use crate::{conn, Event, Inner, PeerHandle};
/// Max files we return in a single search reply.
const MAX_SEARCH_RESULTS: usize = 100;
/// Match a relayed search against our shares and, if we have hits, send a
/// PeerSearchResponse back to `username`. Runs on its own task.
pub(crate) fn respond_to_search(inner: Arc<Inner>, username: String, token: u32, query: String) {
inner.searches_seen.fetch_add(1, Ordering::Relaxed);
// Never answer our own searches; de-dupe repeated relays of a token.
if username == inner.own_username() {
return;
}
{
let mut answered = inner.answered_searches.lock().unwrap();
if answered.contains(&token) {
return;
}
answered.insert(token);
// Bound the set so it can't grow without limit.
if answered.len() > 20_000 {
answered.clear();
}
}
if inner.shares.file_count() == 0 {
return;
}
tokio::spawn(async move {
let excluded = inner.excluded();
let files = inner.shares.search(&query, &excluded, MAX_SEARCH_RESULTS);
if files.is_empty() {
return;
}
let frame = peer::search_response(
&inner.own_username(),
token,
&files,
inner.has_free_upload_slot(),
inner.upload_speed.load(Ordering::Relaxed),
inner.upload_queue.lock().unwrap().len() as u32,
);
// Search replies use a normal P connection to the searcher.
match conn::get_or_connect_peer(&inner, &username).await {
Ok(handle) => {
if handle.write_tx.send(frame).is_ok() {
let _ = inner.event_tx.send(Event::RespondedToSearch {
username: username.clone(),
files: files.len(),
});
}
}
Err(e) => log::debug!("couldn't reply to search from {username}: {e}"),
}
});
}
/// Answer a BrowseRequest on the same connection.
pub(crate) fn respond_browse(inner: &Arc<Inner>, handle: &PeerHandle) {
let dirs = inner.shares.browse();
let _ = handle.write_tx.send(peer::browse_response(&dirs));
}
/// Answer a UserInfoRequest.
pub(crate) fn respond_user_info(inner: &Arc<Inner>, handle: &PeerHandle) {
let frame = peer::user_info_response(
&inner.description,
inner.upload_slots,
inner.upload_queue.lock().unwrap().len() as u32,
inner.has_free_upload_slot(),
);
let _ = handle.write_tx.send(frame);
}
/// Answer a FolderContentsRequest for one folder.
pub(crate) fn respond_folder_contents(
inner: &Arc<Inner>,
handle: &PeerHandle,
token: u32,
folder: &str,
) {
if let Some(dir) = inner.shares.folder(folder) {
let _ = handle
.write_tx
.send(peer::folder_contents_response(token, folder, &dir));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Share index: the files we make available to the network. Built by
//! walking configured folders; queried by the search and browse responders.
//!
//! Remote paths use backslashes and a virtual root so we never leak local
//! absolute paths. Each shared folder is mounted under its own base name,
//! e.g. local `D:\Music\Album` → remote `Music\Album\track.flac`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use soulseek_proto::types::{Directory, File};
const AUDIO_EXTS: [&str; 10] = [
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
];
/// One shared file: its remote (virtual) path and where it lives locally.
#[derive(Debug, Clone)]
struct SharedFile {
remote_path: String, // backslash-separated
local_path: PathBuf,
size: u64,
extension: String,
lowercase: String, // remote_path lowercased, for matching
}
#[derive(Default)]
struct Index {
files: Vec<SharedFile>,
/// remote folder (backslashes) → indices into `files`.
folders: BTreeMap<String, Vec<usize>>,
}
pub struct ShareIndex {
index: RwLock<Index>,
}
impl Default for ShareIndex {
fn default() -> Self {
ShareIndex {
index: RwLock::new(Index::default()),
}
}
}
impl ShareIndex {
pub fn new() -> Self {
Self::default()
}
/// Rebuild the index from the given local folders. Returns (dirs, files).
pub fn rebuild(&self, roots: &[PathBuf]) -> (u32, u32) {
let mut index = Index::default();
for root in roots {
let base = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Shared".into());
walk(root, &base, root, &mut index);
}
let dirs = index.folders.len() as u32;
let files = index.files.len() as u32;
*self.index.write().unwrap() = index;
(dirs, files)
}
pub fn counts(&self) -> (u32, u32) {
let idx = self.index.read().unwrap();
(idx.folders.len() as u32, idx.files.len() as u32)
}
pub fn file_count(&self) -> u32 {
self.index.read().unwrap().files.len() as u32
}
/// Match a search query: every whitespace token must appear somewhere
/// in the remote path (case-insensitive). Returns proto `File`s.
/// `excluded` phrases veto a result if any appears in the path.
pub fn search(&self, query: &str, excluded: &[String], limit: usize) -> Vec<File> {
let terms: Vec<String> = query
.split_whitespace()
.map(|t| t.to_lowercase())
.filter(|t| !t.is_empty())
.collect();
if terms.is_empty() {
return Vec::new();
}
let idx = self.index.read().unwrap();
let mut out = Vec::new();
for f in &idx.files {
if terms.iter().all(|t| f.lowercase.contains(t.as_str()))
&& !excluded
.iter()
.any(|p| !p.is_empty() && f.lowercase.contains(&p.to_lowercase()))
{
out.push(to_proto_file(f));
if out.len() >= limit {
break;
}
}
}
out
}
/// Resolve a remote path a peer asked to download to its local file.
pub fn resolve(&self, remote_path: &str) -> Option<(PathBuf, u64)> {
let idx = self.index.read().unwrap();
idx.files
.iter()
.find(|f| f.remote_path.eq_ignore_ascii_case(remote_path))
.map(|f| (f.local_path.clone(), f.size))
}
/// Full directory listing for a browse response.
pub fn browse(&self) -> Vec<Directory> {
let idx = self.index.read().unwrap();
idx.folders
.iter()
.map(|(name, indices)| Directory {
name: name.clone(),
files: indices
.iter()
.map(|&i| to_proto_file(&idx.files[i]))
.collect(),
})
.collect()
}
/// Files in one folder (for FolderContentsResponse).
pub fn folder(&self, name: &str) -> Option<Directory> {
let idx = self.index.read().unwrap();
idx.folders.get(name).map(|indices| Directory {
name: name.to_string(),
files: indices
.iter()
.map(|&i| to_proto_file(&idx.files[i]))
.collect(),
})
}
}
fn to_proto_file(f: &SharedFile) -> File {
// Search/browse responses send just the leaf filename in the File entry
// for search, but the full path for browse. We store the remote path
// and let the responder decide; here we return the remote path as the
// filename (peers expect a full backslash path in search responses).
File {
filename: f.remote_path.clone(),
size: f.size,
extension: f.extension.clone(),
attributes: Vec::new(),
}
}
fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, base, root, index);
} else if is_audio(&path) {
let Ok(meta) = entry.metadata() else { continue };
let remote_path = to_remote_path(&path, base, root);
let folder = remote_path
.rsplit_once('\\')
.map(|(d, _)| d.to_string())
.unwrap_or_else(|| base.to_string());
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let file = SharedFile {
lowercase: remote_path.to_lowercase(),
remote_path,
local_path: path,
size: meta.len(),
extension: ext,
};
let idx = index.files.len();
index.folders.entry(folder).or_default().push(idx);
index.files.push(file);
}
}
}
/// `<base>\<relative path with backslashes>`.
fn to_remote_path(path: &Path, base: &str, root: &Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
let rel_str: String = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("\\");
format!("{base}\\{rel_str}")
}
fn is_audio(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_search_and_resolve() {
let dir = std::env::temp_dir().join("ssplus-share-test");
let album = dir.join("Nevermind");
std::fs::create_dir_all(&album).unwrap();
std::fs::write(album.join("01 Lithium.flac"), b"fake").unwrap();
std::fs::write(album.join("02 Polly.mp3"), b"fake2").unwrap();
std::fs::write(album.join("cover.jpg"), b"img").unwrap(); // ignored
let share = ShareIndex::new();
let (dirs, files) = share.rebuild(&[dir.clone()]);
assert_eq!(files, 2);
assert!(dirs >= 1);
// Matches on both terms across the path.
let hits = share.search("nevermind lithium", &[], 10);
assert_eq!(hits.len(), 1);
assert!(hits[0].filename.ends_with("01 Lithium.flac"));
// Excluded phrase vetoes.
let vetoed = share.search("lithium", &["nevermind".into()], 10);
assert!(vetoed.is_empty());
// Resolve remote → local.
let remote = &hits[0].filename;
let (local, size) = share.resolve(remote).unwrap();
assert!(local.ends_with("01 Lithium.flac"));
assert_eq!(size, 4);
// Browse lists the folder.
let browse = share.browse();
assert!(browse
.iter()
.any(|d| d.name.contains("Nevermind") && d.files.len() == 2));
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Download state machine, mirroring Soulseek.NET's `DownloadToStreamAsync`:
//!
//! 1. Send `TransferRequest(download, token, filename)` on the P connection.
//! 2. Peer answers `TransferResponse`:
//! - allowed → we initiate the F connection with our token.
//! - "Queued" → wait for the peer's `TransferRequest(upload)`, ack it,
//! await their inbound F connection (second-chance: initiate ours).
//! 3. On the F connection: write 8-byte start offset, then read raw bytes.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use soulseek_proto::peer;
use crate::{conn, ClientError, Event, Inner, Result};
/// How long we stay in the remote queue before giving up.
const QUEUE_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
/// Ack / connection-establishment timeout.
const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(20);
/// No bytes for this long mid-transfer → fail.
const TRANSFER_STALL_TIMEOUT: Duration = Duration::from_secs(90);
/// Poll the remote queue position at this interval while queued.
const PLACE_POLL_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DownloadState {
Connecting,
Requested,
Queued { place: Option<u32> },
Transferring,
Complete { local_path: String },
Failed { reason: String },
Cancelled,
}
#[derive(Debug, Clone)]
pub struct DownloadUpdate {
pub id: u64,
pub username: String,
/// Full remote path (backslash separated).
pub filename: String,
pub state: DownloadState,
pub received: u64,
pub total: u64,
pub speed_bps: u64,
}
pub(crate) fn start_download(
inner: Arc<Inner>,
username: &str,
filename: &str,
size: u64,
target: Option<PathBuf>,
) -> u64 {
let id = inner
.download_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let cancel = CancellationToken::new();
inner.downloads_by_file.lock().unwrap().insert(
(username.to_string(), filename.to_string()),
(id, cancel.clone()),
);
let username = username.to_string();
let filename = filename.to_string();
tokio::spawn(async move {
let mut dl = Download {
inner: inner.clone(),
id,
username: username.clone(),
filename: filename.clone(),
target,
total: size,
received: 0,
speed_bps: 0,
cancel: cancel.clone(),
};
let outcome = tokio::select! {
r = dl.run() => r,
_ = cancel.cancelled() => Err(ClientError::Timeout), // mapped below
};
match outcome {
Ok(local_path) => dl.emit(DownloadState::Complete { local_path }),
Err(_) if cancel.is_cancelled() => dl.emit(DownloadState::Cancelled),
Err(e) => dl.emit(DownloadState::Failed {
reason: e.to_string(),
}),
}
inner
.downloads_by_file
.lock()
.unwrap()
.remove(&(username, filename));
});
id
}
/// Emit a queue-place update for a download (from PlaceInQueueResponse).
pub(crate) fn update_queue_place(inner: &Arc<Inner>, username: &str, filename: &str, place: u32) {
let id = inner
.downloads_by_file
.lock()
.unwrap()
.get(&(username.to_string(), filename.to_string()))
.map(|(id, _)| *id);
if let Some(id) = id {
let _ = inner.event_tx.send(Event::Download(DownloadUpdate {
id,
username: username.to_string(),
filename: filename.to_string(),
state: DownloadState::Queued { place: Some(place) },
received: 0,
total: 0,
speed_bps: 0,
}));
}
}
struct Download {
inner: Arc<Inner>,
id: u64,
username: String,
filename: String,
/// Explicit local target (stream-to-cache); None → downloads dir layout.
target: Option<PathBuf>,
total: u64,
received: u64,
speed_bps: u64,
cancel: CancellationToken,
}
impl Download {
fn emit(&self, state: DownloadState) {
let _ = self.inner.event_tx.send(Event::Download(DownloadUpdate {
id: self.id,
username: self.username.clone(),
filename: self.filename.clone(),
state,
received: self.received,
total: self.total,
speed_bps: self.speed_bps,
}));
}
async fn run(&mut self) -> Result<String> {
self.emit(DownloadState::Connecting);
let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?;
// Register both waits before sending the request to avoid races:
// the immediate ack, and the eventual upload offer if we get queued.
let token = self.inner.next_token();
let (ack_tx, ack_rx) = oneshot::channel();
self.inner
.transfer_waits
.lock()
.unwrap()
.insert((self.username.clone(), token), ack_tx);
let (offer_tx, offer_rx) = oneshot::channel();
self.inner
.upload_offers
.lock()
.unwrap()
.insert((self.username.clone(), self.filename.clone()), offer_tx);
handle
.write_tx
.send(peer::transfer_request_download(token, &self.filename))
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
self.emit(DownloadState::Requested);
let ack = tokio::time::timeout(NEGOTIATION_TIMEOUT, ack_rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
let stream = if ack.allowed {
// Free slot: we initiate the transfer connection with our token.
if ack.size > 0 {
self.total = ack.size;
}
self.emit(DownloadState::Transferring);
conn::connect_transfer(&self.inner, &self.username, token).await?
} else if ack
.message
.trim_end_matches('.')
.eq_ignore_ascii_case("queued")
{
self.emit(DownloadState::Queued { place: None });
self.await_queued_transfer(&handle, offer_rx).await?
} else {
return Err(ClientError::TransferRejected(ack.message));
};
self.receive(stream).await
}
/// We're in the remote queue: poll our place, wait for the peer's
/// upload offer, ack it, and obtain the transfer connection.
async fn await_queued_transfer(
&mut self,
handle: &crate::PeerHandle,
offer_rx: oneshot::Receiver<crate::UploadOffer>,
) -> Result<TcpStream> {
// Queue-place polling until the offer arrives.
let poll_handle = handle.write_tx.clone();
let poll_filename = self.filename.clone();
let poller = tokio::spawn(async move {
loop {
tokio::time::sleep(PLACE_POLL_INTERVAL).await;
if poll_handle
.send(peer::place_in_queue_request(&poll_filename))
.is_err()
{
break;
}
}
});
let offer = tokio::time::timeout(QUEUE_TIMEOUT, offer_rx).await;
poller.abort();
let (remote_token, size) = offer
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?
.map_err(ClientError::TransferRejected)?;
if size > 0 {
self.total = size;
}
// Expect the peer's inbound F connection carrying their token —
// registered before we ack so the connection can't race us.
let (conn_tx, conn_rx) = oneshot::channel();
self.inner
.expected_transfers
.lock()
.unwrap()
.insert((self.username.clone(), remote_token), conn_tx);
// Re-resolve the peer connection in case the old one idled out.
let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?;
handle
.write_tx
.send(peer::transfer_response_allowed(remote_token, self.total))
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
self.emit(DownloadState::Transferring);
match tokio::time::timeout(NEGOTIATION_TIMEOUT, conn_rx).await {
Ok(Ok(stream)) => Ok(stream),
_ => {
// Second chance (Nicotine+ et al.): initiate from our end.
self.inner
.expected_transfers
.lock()
.unwrap()
.remove(&(self.username.clone(), remote_token));
log::debug!(
"no inbound F connection from {}; initiating second-chance",
self.username
);
conn::connect_transfer(&self.inner, &self.username, remote_token).await
}
}
}
/// Drive the transfer connection: write start offset, stream to disk.
async fn receive(&mut self, mut stream: TcpStream) -> Result<String> {
let (final_path, partial_path) = self.local_paths()?;
// Resume from an existing partial file when its length is sane.
let offset = match tokio::fs::metadata(&partial_path).await {
Ok(m) if self.total > 0 && m.len() < self.total => m.len(),
_ => 0,
};
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&partial_path)
.await?;
file.set_len(offset).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
stream.write_all(&offset.to_le_bytes()).await?;
self.received = offset;
self.emit(DownloadState::Transferring);
let mut buf = vec![0u8; 64 * 1024];
let mut window_start = Instant::now();
let mut window_bytes: u64 = 0;
let mut last_emit = Instant::now();
while self.total == 0 || self.received < self.total {
let n = tokio::select! {
_ = self.cancel.cancelled() => return Err(ClientError::Timeout),
r = tokio::time::timeout(TRANSFER_STALL_TIMEOUT, stream.read(&mut buf)) => {
r.map_err(|_| ClientError::Timeout)??
}
};
if n == 0 {
if self.total == 0 {
break; // size unknown: EOF is completion
}
return Err(ClientError::PeerUnreachable(format!(
"{} closed the connection early ({}/{} bytes)",
self.username, self.received, self.total
)));
}
file.write_all(&buf[..n]).await?;
self.received += n as u64;
window_bytes += n as u64;
let elapsed = window_start.elapsed();
if elapsed >= Duration::from_secs(1) {
self.speed_bps = (window_bytes as f64 / elapsed.as_secs_f64()) as u64;
window_start = Instant::now();
window_bytes = 0;
}
if last_emit.elapsed() >= Duration::from_millis(250) {
last_emit = Instant::now();
self.emit(DownloadState::Transferring);
}
}
file.flush().await?;
drop(file);
let final_path = unique_path(final_path).await;
tokio::fs::rename(&partial_path, &final_path).await?;
Ok(final_path.to_string_lossy().into_owned())
}
/// `<downloads_dir>/<user>/<remote folder leaf>/<file>` (+ `.partial`),
/// or the explicit target when set.
fn local_paths(&self) -> Result<(PathBuf, PathBuf)> {
if let Some(target) = &self.target {
if let Some(dir) = target.parent() {
std::fs::create_dir_all(dir)?;
}
let partial = target.with_extension(format!(
"{}partial",
target
.extension()
.map(|e| format!("{}.", e.to_string_lossy()))
.unwrap_or_default()
));
return Ok((target.clone(), partial));
}
let (folder, name) = match self.filename.rsplit_once('\\') {
Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n),
None => ("", self.filename.as_str()),
};
let mut dir = self.inner.downloads_dir.clone();
dir.push(sanitize(&self.username));
if !folder.is_empty() {
dir.push(sanitize(folder));
}
std::fs::create_dir_all(&dir)?;
let final_path = dir.join(sanitize(name));
let partial = dir.join(format!("{}.partial", sanitize(name)));
Ok((final_path, partial))
}
}
/// Make a remote path component safe for Windows.
fn sanitize(part: &str) -> String {
let cleaned: String = part
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
cleaned.trim_end_matches([' ', '.']).to_string()
}
/// Avoid clobbering an existing file: append " (2)", " (3)", …
async fn unique_path(path: PathBuf) -> PathBuf {
if tokio::fs::metadata(&path).await.is_err() {
return path;
}
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let dir = path.parent().map(PathBuf::from).unwrap_or_default();
for i in 2.. {
let candidate = dir.join(format!("{stem} ({i}){ext}"));
if tokio::fs::metadata(&candidate).await.is_err() {
return candidate;
}
}
unreachable!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_windows_reserved() {
assert_eq!(sanitize("AC/DC: Back in Black?"), "AC_DC_ Back in Black_");
assert_eq!(sanitize("trailing dots..."), "trailing dots");
}
}
+334
View File
@@ -0,0 +1,334 @@
//! Upload servicing: when a peer requests one of our shared files, we send
//! it over an F connection. Slot-limited with a simple FIFO queue.
//!
//! Two protocol sub-flows, mirroring the download side (M2):
//!
//! * **Immediate** — peer sends `TransferRequest(download, their_token)`.
//! With a free slot we reply `TransferResponse(their_token, allowed)`,
//! then await *their* inbound F connection (keyed by their token), read
//! the 8-byte offset, and stream the file.
//! * **Queued / proactive** — no slot yet (or a bare `QueueDownload`). We
//! enqueue; when a slot frees we send `TransferRequest(upload, our_token)`,
//! await their `TransferResponse`, open the F connection ourselves, read
//! the offset, and stream.
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::oneshot;
use soulseek_proto::peer;
use crate::{conn, ClientError, Event, Inner, Result};
const ACK_TIMEOUT: Duration = Duration::from_secs(30);
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UploadState {
Queued,
Transferring,
Complete,
Failed { reason: String },
}
#[derive(Debug, Clone)]
pub struct UploadUpdate {
pub id: u64,
pub username: String,
pub filename: String,
pub state: UploadState,
pub sent: u64,
pub total: u64,
}
/// Peer sent `TransferRequest(download, their_token, filename)`.
pub(crate) fn on_transfer_request(
inner: Arc<Inner>,
username: String,
their_token: u32,
remote_path: String,
) {
let Some((local, size)) = inner.shares.resolve(&remote_path) else {
reply(
&inner,
&username,
peer::transfer_response_rejected(their_token, "File not shared"),
);
return;
};
if inner.has_free_upload_slot() {
// Accept: they will open the F connection with their token.
reply(
&inner,
&username,
peer::transfer_response_allowed(their_token, size),
);
spawn_immediate(inner, username, remote_path, local, size, their_token);
} else {
reply(
&inner,
&username,
peer::transfer_response_rejected(their_token, "Queued"),
);
enqueue(&inner, &username, &remote_path);
}
}
/// Peer sent a bare `QueueDownload(filename)` (no token) — enqueue, or start
/// the proactive flow immediately if a slot is free.
pub(crate) fn on_queue_download(inner: Arc<Inner>, username: String, remote_path: String) {
let Some((local, size)) = inner.shares.resolve(&remote_path) else {
reply(
&inner,
&username,
peer::upload_denied(&remote_path, "File not shared"),
);
return;
};
if inner.has_free_upload_slot() {
spawn_proactive(inner, username, remote_path, local, size);
} else {
enqueue(&inner, &username, &remote_path);
}
}
/// Report queue place when a peer asks (PlaceInQueueRequest).
pub(crate) fn on_place_query(inner: &Arc<Inner>, username: &str, remote_path: &str) {
let place = inner
.upload_queue
.lock()
.unwrap()
.iter()
.position(|(u, p)| u == username && p == remote_path)
.map(|i| i as u32 + 1)
.unwrap_or(0);
reply(
inner,
username,
peer::place_in_queue_response(remote_path, place),
);
}
fn enqueue(inner: &Arc<Inner>, username: &str, remote_path: &str) {
let mut q = inner.upload_queue.lock().unwrap();
if !q.iter().any(|(u, p)| u == username && p == remote_path) && q.len() < 500 {
q.push_back((username.to_string(), remote_path.to_string()));
}
let place = q.len() as u32;
drop(q);
reply(
inner,
username,
peer::place_in_queue_response(remote_path, place),
);
}
fn reply(inner: &Arc<Inner>, username: &str, frame: Vec<u8>) {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
let _ = h.write_tx.send(frame);
}
}
/// After a slot frees, start the next queued upload (proactive flow).
fn pump_queue(inner: Arc<Inner>) {
if !inner.has_free_upload_slot() {
return;
}
let next = inner.upload_queue.lock().unwrap().pop_front();
if let Some((username, remote_path)) = next {
if let Some((local, size)) = inner.shares.resolve(&remote_path) {
spawn_proactive(inner, username, remote_path, local, size);
}
}
}
// -------- immediate flow (peer opens the F connection) --------
fn spawn_immediate(
inner: Arc<Inner>,
username: String,
remote_path: String,
local: PathBuf,
size: u64,
their_token: u32,
) {
inner.uploads_active.fetch_add(1, Ordering::Relaxed);
let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed);
// Register to receive their inbound F connection (keyed by their token).
let (conn_tx, conn_rx) = oneshot::channel();
inner
.expected_transfers
.lock()
.unwrap()
.insert((username.clone(), their_token), conn_tx);
tokio::spawn(async move {
let user_err = username.clone();
run_upload(
&inner,
id,
&username,
&remote_path,
&local,
size,
async move {
let stream = tokio::time::timeout(ACK_TIMEOUT, conn_rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(user_err))?;
Ok::<TcpStream, ClientError>(stream)
},
)
.await;
inner
.expected_transfers
.lock()
.unwrap()
.remove(&(username.clone(), their_token));
inner.uploads_active.fetch_sub(1, Ordering::Relaxed);
pump_queue(inner.clone());
});
}
// -------- proactive flow (we open the F connection) --------
fn spawn_proactive(
inner: Arc<Inner>,
username: String,
remote_path: String,
local: PathBuf,
size: u64,
) {
inner.uploads_active.fetch_add(1, Ordering::Relaxed);
let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move {
let inner2 = inner.clone();
let user2 = username.clone();
let path2 = remote_path.clone();
run_upload(
&inner,
id,
&username,
&remote_path,
&local,
size,
async move {
// Offer the upload and wait for acceptance.
let handle = conn::get_or_connect_peer(&inner2, &user2).await?;
let token = inner2.next_token();
let (ack_tx, ack_rx) = oneshot::channel();
inner2
.transfer_waits
.lock()
.unwrap()
.insert((user2.clone(), token), ack_tx);
handle
.write_tx
.send(peer::transfer_request_upload(token, &path2, size))
.map_err(|_| ClientError::PeerUnreachable(user2.clone()))?;
let ack = tokio::time::timeout(ACK_TIMEOUT, ack_rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(user2.clone()))?;
if !ack.allowed {
return Err(ClientError::TransferRejected(ack.message));
}
conn::connect_transfer(&inner2, &user2, token).await
},
)
.await;
inner.uploads_active.fetch_sub(1, Ordering::Relaxed);
pump_queue(inner.clone());
});
}
/// Shared upload driver: obtain the F stream via `get_stream`, read the
/// 8-byte start offset, and stream the file from there.
async fn run_upload<F>(
inner: &Arc<Inner>,
id: u64,
username: &str,
remote_path: &str,
local: &PathBuf,
size: u64,
get_stream: F,
) where
F: std::future::Future<Output = Result<TcpStream>>,
{
let emit = |state: UploadState, sent: u64| {
let _ = inner.event_tx.send(Event::Upload(UploadUpdate {
id,
username: username.to_string(),
filename: remote_path.to_string(),
state,
sent,
total: size,
}));
};
emit(UploadState::Transferring, 0);
let result = async {
let mut stream = get_stream.await?;
let mut off = [0u8; 8];
tokio::time::timeout(ACK_TIMEOUT, stream.read_exact(&mut off))
.await
.map_err(|_| ClientError::Timeout)??;
let offset = u64::from_le_bytes(off).min(size);
let mut file = tokio::fs::File::open(local).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut sent = offset;
let mut buf = vec![0u8; 64 * 1024];
let mut window = Instant::now();
let mut window_bytes: u64 = 0;
let mut last_emit = Instant::now();
while sent < size {
let n = file.read(&mut buf).await?;
if n == 0 {
break;
}
tokio::time::timeout(STALL_TIMEOUT, stream.write_all(&buf[..n]))
.await
.map_err(|_| ClientError::Timeout)??;
sent += n as u64;
window_bytes += n as u64;
let elapsed = window.elapsed();
if elapsed >= Duration::from_secs(2) {
inner.upload_speed.store(
(window_bytes as f64 / elapsed.as_secs_f64()) as u32,
Ordering::Relaxed,
);
window = Instant::now();
window_bytes = 0;
}
if last_emit.elapsed() >= Duration::from_millis(300) {
last_emit = Instant::now();
emit(UploadState::Transferring, sent);
}
}
stream.flush().await?;
Ok::<(), ClientError>(())
}
.await;
match result {
Ok(()) => emit(UploadState::Complete, size),
Err(e) => emit(
UploadState::Failed {
reason: e.to_string(),
},
0,
),
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Best-effort UPnP port mapping so firewalled peers can reach our
//! listener directly. Failure is non-fatal — Soulseek's indirect
//! (server-relayed) connection path still works without it.
use std::net::SocketAddrV4;
use std::time::Duration;
/// Try to map `port` (TCP) on the local gateway to ourselves. Returns the
/// external address on success. Runs on a blocking thread.
pub(crate) async fn try_map_port(port: u16) {
let result = tokio::task::spawn_blocking(move || map_blocking(port)).await;
match result {
Ok(Ok(external)) => log::info!("UPnP: mapped port {port} (external {external})"),
Ok(Err(e)) => log::info!("UPnP unavailable ({e}); relying on indirect connections"),
Err(_) => log::debug!("UPnP task panicked"),
}
}
fn map_blocking(port: u16) -> Result<SocketAddrV4, String> {
use igd_next::{search_gateway, PortMappingProtocol, SearchOptions};
let gateway = search_gateway(SearchOptions {
timeout: Some(Duration::from_secs(5)),
..Default::default()
})
.map_err(|e| e.to_string())?;
// Find our LAN address facing the gateway.
let local_ip = local_ipv4().ok_or_else(|| "no local IPv4".to_string())?;
let local = SocketAddrV4::new(local_ip, port);
gateway
.add_port(
PortMappingProtocol::TCP,
port,
std::net::SocketAddr::V4(local),
0, // lease 0 = indefinite (until router reboots)
"SoulStream",
)
.map_err(|e| e.to_string())?;
match gateway.get_external_ip() {
Ok(std::net::IpAddr::V4(ip)) => Ok(SocketAddrV4::new(ip, port)),
_ => Ok(local),
}
}
/// Discover our LAN IPv4 by opening a UDP socket toward a public address
/// (no packets are actually sent).
fn local_ipv4() -> Option<std::net::Ipv4Addr> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
match sock.local_addr().ok()?.ip() {
std::net::IpAddr::V4(ip) => Some(ip),
_ => None,
}
}