feat: direct peer connections — download() connects to unconnected peers
Implement the DIRECT connection-initiation mode so a download can target a peer the client is not already connected to: - Fix the outbound peer handshake. on_connection_established sent build_watch_user() (a server WatchUser message, wrongly framed with get_data()) for outbound connections; it now sends a proper length-prefixed PeerInit (peer code 1) with our own username. own_username is plumbed in via PeerRegistry -> PeerActor; inbound peers (outbound=false) are unchanged. - download() now auto-connects: if there is no control connection to the peer it sends GetPeerAddress and defers the QueueUpload until the outbound stream handshakes, signalled by a new ClientOperation::PeerConnected. A never- connected client still resolves the download to Failed (unit test). - Add Client::connect_peer(username) to open/pre-warm a control connection. Verified end to end against soulfind: a_file_downloads_from_a_peer_via_direct_connection logs a mock peer into the server, advertises its port via SetWaitPort, and the client resolves the address, dials it, and downloads the file — bytes match. The F-serve leg is shared with the passive-download mock.
This commit is contained in:
@@ -49,6 +49,10 @@ pub struct PeerActor {
|
||||
dispatcher: Option<MessageDispatcher<PeerMessage>>,
|
||||
dispatcher_receiver: Option<Receiver<PeerMessage>>,
|
||||
queued_messages: Vec<PeerMessage>,
|
||||
own_username: String,
|
||||
/// True when we initiated this connection (no stream supplied at
|
||||
/// construction), so we must send a `PeerInit` once connected.
|
||||
outbound: bool,
|
||||
}
|
||||
|
||||
impl PeerActor {
|
||||
@@ -58,7 +62,9 @@ impl PeerActor {
|
||||
stream: Option<TcpStream>,
|
||||
reader: Option<MessageReader>,
|
||||
client_channel: Sender<ClientOperation>,
|
||||
own_username: String,
|
||||
) -> Self {
|
||||
let outbound = stream.is_none();
|
||||
let connection_state = if stream.is_some() {
|
||||
ConnectionState::Connected
|
||||
} else {
|
||||
@@ -75,6 +81,8 @@ impl PeerActor {
|
||||
dispatcher: None,
|
||||
dispatcher_receiver: None,
|
||||
queued_messages: Vec::new(),
|
||||
own_username,
|
||||
outbound,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,8 +537,12 @@ impl PeerActor {
|
||||
}
|
||||
|
||||
fn on_connection_established(&mut self) {
|
||||
let (username, token) = match self.peer.read_safe() {
|
||||
Ok(peer) => (peer.username.clone(), peer.token),
|
||||
let (username, connection_type, token) = match self.peer.read_safe() {
|
||||
Ok(peer) => (
|
||||
peer.username.clone(),
|
||||
peer.connection_type.clone(),
|
||||
peer.token,
|
||||
),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[peer_actor] on_connection_established peer lock: {}",
|
||||
@@ -544,11 +556,19 @@ impl PeerActor {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(token) = token {
|
||||
let handshake_msg = MessageFactory::build_watch_user(token);
|
||||
if let Err(e) = stream.write_all(&handshake_msg.get_data()) {
|
||||
// Connections we initiated must announce themselves with a PeerInit
|
||||
// (peer code 1); inbound peers already sent us theirs, so we stay
|
||||
// silent for them. `get_buffer` prepends the length prefix the peer's
|
||||
// MessageReader expects.
|
||||
if self.outbound {
|
||||
let init = MessageFactory::build_peer_init_message(
|
||||
&self.own_username,
|
||||
connection_type,
|
||||
token.unwrap_or(0),
|
||||
);
|
||||
if let Err(e) = stream.write_all(&init.get_buffer()) {
|
||||
error!(
|
||||
"[peer:{}] Failed to send watch user handshake: {}",
|
||||
"[peer:{}] Failed to send PeerInit handshake: {}",
|
||||
username, e
|
||||
);
|
||||
self.disconnect_with_error(e);
|
||||
@@ -563,6 +583,14 @@ impl PeerActor {
|
||||
self.handle_message(msg);
|
||||
}
|
||||
|
||||
// Tell the client the control connection is live so any downloads
|
||||
// queued for this peer can now be requested over a handshaken stream.
|
||||
if self.outbound {
|
||||
let _ = self
|
||||
.client_channel
|
||||
.send(ClientOperation::PeerConnected(username));
|
||||
}
|
||||
|
||||
if let Some(ref handle) = self.self_handle {
|
||||
handle.send(PeerMessage::ProcessRead).ok();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct PeerRegistry {
|
||||
peers: Arc<Mutex<HashMap<String, ActorHandle<PeerMessage>>>>,
|
||||
actor_system: Arc<ActorSystem>,
|
||||
client_channel: Sender<ClientOperation>,
|
||||
own_username: String,
|
||||
}
|
||||
|
||||
impl PeerRegistry {
|
||||
@@ -22,11 +23,13 @@ impl PeerRegistry {
|
||||
pub fn new(
|
||||
actor_system: Arc<ActorSystem>,
|
||||
client_channel: Sender<ClientOperation>,
|
||||
own_username: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||
actor_system,
|
||||
client_channel,
|
||||
own_username,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +41,13 @@ impl PeerRegistry {
|
||||
) -> Result<ActorHandle<PeerMessage>, String> {
|
||||
let username = peer.username.clone();
|
||||
|
||||
let actor =
|
||||
PeerActor::new(peer, stream, reader, self.client_channel.clone());
|
||||
let actor = PeerActor::new(
|
||||
peer,
|
||||
stream,
|
||||
reader,
|
||||
self.client_channel.clone(),
|
||||
self.own_username.clone(),
|
||||
);
|
||||
|
||||
let handle =
|
||||
self.actor_system.spawn_with_handle(actor, |actor, handle| {
|
||||
@@ -157,6 +165,7 @@ impl Clone for PeerRegistry {
|
||||
peers: self.peers.clone(),
|
||||
actor_system: self.actor_system.clone(),
|
||||
client_channel: self.client_channel.clone(),
|
||||
own_username: self.own_username.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
-11
@@ -97,6 +97,7 @@ pub enum ClientOperation {
|
||||
},
|
||||
SetServerSender(Sender<ServerMessage>),
|
||||
PrivateMessageReceived(UserMessage),
|
||||
PeerConnected(String),
|
||||
}
|
||||
pub struct ClientContext {
|
||||
pub peer_registry: Option<PeerRegistry>,
|
||||
@@ -254,6 +255,26 @@ fn test_client_pause_and_resume_download() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_without_a_connection_resolves_failed() {
|
||||
// A client that never connected has no server handle and no peer registry,
|
||||
// so it cannot open a connection to the peer: the download must resolve to
|
||||
// Failed rather than hang Queued forever.
|
||||
let client = Client::new("test-user", "test-password");
|
||||
let (_download, receiver) = client
|
||||
.download(
|
||||
"song.mp3".to_string(),
|
||||
"peer".to_string(),
|
||||
100,
|
||||
"test".to_string(),
|
||||
)
|
||||
.expect("download() should return a handle");
|
||||
assert!(matches!(
|
||||
receiver.recv_timeout(Duration::from_secs(1)),
|
||||
Ok(DownloadStatus::Failed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_removes_only_queued_downloads() {
|
||||
let client = Client::new("test-user", "test-password");
|
||||
@@ -375,8 +396,11 @@ impl Client {
|
||||
|
||||
let mut ctx = self.context.write_safe()?;
|
||||
ctx.sender = Some(sender.clone());
|
||||
let peer_registry =
|
||||
PeerRegistry::new(ctx.actor_system.clone(), sender.clone());
|
||||
let peer_registry = PeerRegistry::new(
|
||||
ctx.actor_system.clone(),
|
||||
sender.clone(),
|
||||
self.username.clone(),
|
||||
);
|
||||
ctx.peer_registry = Some(peer_registry);
|
||||
|
||||
let listen_sender = sender.clone();
|
||||
@@ -461,6 +485,23 @@ impl Client {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask the server for a peer's address and open a direct control
|
||||
/// connection to it. Downloads queued for that peer are sent automatically
|
||||
/// once the connection is established.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
|
||||
pub fn connect_peer(&self, username: &str) -> Result<()> {
|
||||
let handle = self
|
||||
.server_handle
|
||||
.as_ref()
|
||||
.ok_or(SoulseekRs::NotConnected)?;
|
||||
handle
|
||||
.send(ServerMessage::GetPeerAddress(username.to_string()))
|
||||
.map_err(|_| SoulseekRs::NotConnected)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove and return all private messages received since the last call.
|
||||
#[must_use]
|
||||
pub fn take_private_messages(&self) -> Vec<UserMessage> {
|
||||
@@ -684,18 +725,29 @@ impl Client {
|
||||
let mut context = self.context.write_safe()?;
|
||||
context.add_download(download.clone());
|
||||
|
||||
let download_initiated =
|
||||
if let Some(ref registry) = context.peer_registry {
|
||||
registry
|
||||
.queue_upload(&username, download.filename.clone())
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// If we already have a control connection to this peer, queue the
|
||||
// upload immediately. Otherwise open one directly (server GetPeerAddress
|
||||
// → outbound PeerInit → PeerConnected → the queued upload is flushed).
|
||||
let peer_registered = context
|
||||
.peer_registry
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.contains(&username));
|
||||
let queued_now = peer_registered
|
||||
&& context.peer_registry.as_ref().is_some_and(|r| {
|
||||
r.queue_upload(&username, download.filename.clone()).is_ok()
|
||||
});
|
||||
|
||||
drop(context);
|
||||
|
||||
if !download_initiated {
|
||||
let failed = if peer_registered {
|
||||
!queued_now
|
||||
} else {
|
||||
// No existing connection: initiate one. Only a genuinely
|
||||
// unconnected client (no server handle) fails outright here.
|
||||
self.connect_peer(&username).is_err()
|
||||
};
|
||||
|
||||
if failed {
|
||||
let _ = download.sender.send(DownloadStatus::Failed);
|
||||
self.context
|
||||
.write_safe()?
|
||||
@@ -1187,6 +1239,44 @@ impl Client {
|
||||
e
|
||||
),
|
||||
},
|
||||
ClientOperation::PeerConnected(username) => {
|
||||
// An outbound control connection just handshook.
|
||||
// Flush any downloads that were queued for this
|
||||
// peer while we were still connecting. Collect
|
||||
// under a read guard, then act without it held.
|
||||
let (registry, files): (
|
||||
Option<PeerRegistry>,
|
||||
Vec<String>,
|
||||
) = match client_context.read_safe() {
|
||||
Ok(ctx) => (
|
||||
ctx.peer_registry.clone(),
|
||||
ctx.get_downloads()
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.username == username
|
||||
&& matches!(
|
||||
d.status,
|
||||
DownloadStatus::Queued
|
||||
)
|
||||
})
|
||||
.map(|d| d.filename.clone())
|
||||
.collect(),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[client] PeerConnected read: {}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(registry) = registry {
|
||||
for filename in files {
|
||||
let _ = registry
|
||||
.queue_upload(&username, filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -142,7 +142,6 @@ impl MessageFactory {
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn build_peer_init_message(
|
||||
own_username: &str,
|
||||
@@ -184,6 +183,22 @@ fn test_build_login_message() {
|
||||
assert_eq!(expect, message.get_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_peer_init_message() {
|
||||
use crate::peer::ConnectionType;
|
||||
let message =
|
||||
MessageFactory::build_peer_init_message("bob", ConnectionType::P, 7);
|
||||
// [1][len=3]"bob"[len=1]"P"[token=7] — no length prefix in get_data()
|
||||
let expect: Vec<u8> = [
|
||||
1, // PeerInit code (int8)
|
||||
3, 0, 0, 0, 98, 111, 98, // username "bob"
|
||||
1, 0, 0, 0, 80, // connection type "P"
|
||||
7, 0, 0, 0, // token
|
||||
]
|
||||
.to_vec();
|
||||
assert_eq!(expect, message.get_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_message_user() {
|
||||
let message = MessageFactory::build_message_user("bob", "hi");
|
||||
|
||||
Reference in New Issue
Block a user