feat(lib): answer UserInfoRequest with our slots and queue

Opening a user's profile in SoulseekQt or Nicotine+ asks that peer
directly (peer code 15), and a peer that never answers shows an endless
spinner. The reply (code 16) carries no description or picture, the
configured upload slot count, how many uploads are waiting, and whether a
slot is free right now, which is what the reference clients send.
This commit is contained in:
Michel de Graaf
2026-09-07 15:32:30 +02:00
parent 7c4d9e2c9a
commit 73208ae927
6 changed files with 125 additions and 36 deletions
+24 -36
View File
@@ -6,6 +6,7 @@ use crate::message::peer::{
PlaceInQueueRequest, PlaceInQueueResponse, QueueUploadHandler,
SharedDirectory, SharedFileListResponseHandler, TransferRequest,
TransferResponse, UploadDeniedHandler, UploadFailedHandler,
UserInfoRequest,
};
use crate::message::server::MessageFactory;
use crate::message::{Handlers, Message, MessageReader, MessageType};
@@ -46,6 +47,8 @@ pub enum PeerMessage {
ShareListRequested,
/// A peer we are browsing sent us their shared-file listing (code 5).
ShareListReceived(Vec<SharedDirectory>),
/// A peer asked what we say about ourselves (they sent us code 15).
UserInfoRequested,
/// A peer asked for one folder of our shares (they sent us code 36).
FolderContentsRequested {
token: u32,
@@ -184,6 +187,7 @@ impl PeerActor {
handlers.register_handler(TransferResponse);
handlers.register_handler(GetShareFileList);
handlers.register_handler(FolderContentsRequest);
handlers.register_handler(UserInfoRequest);
handlers.register_handler(UploadDeniedHandler);
handlers.register_handler(UploadFailedHandler);
handlers.register_handler(PlaceInQueueRequest);
@@ -274,13 +278,27 @@ impl PeerActor {
self.handle_serve_upload(token, filename, size);
}
PeerMessage::ShareListRequested => {
self.handle_share_list_requested();
self.forward(ClientOperation::ShareListRequested {
requester_key: self.peer_username(),
});
}
PeerMessage::ShareListReceived(directories) => {
self.handle_share_list_received(directories);
self.forward(ClientOperation::BrowseResult {
username: self.peer_username(),
directories,
});
}
PeerMessage::UserInfoRequested => {
self.forward(ClientOperation::UserInfoRequested {
requester_key: self.peer_username(),
});
}
PeerMessage::FolderContentsRequested { token, folder } => {
self.handle_folder_contents_requested(token, folder);
self.forward(ClientOperation::FolderContentsRequested {
requester_key: self.peer_username(),
token,
folder,
});
}
PeerMessage::RequestTransfer(download) => {
let message = MessageFactory::build_transfer_request_message(
@@ -440,39 +458,9 @@ impl PeerActor {
self.send_message(message);
}
fn handle_share_list_requested(&self) {
let requester_key = self.peer_username();
if let Err(e) = self
.client_channel
.send(ClientOperation::ShareListRequested { requester_key })
{
error!("[peer_actor] forward ShareListRequested: {}", e);
}
}
fn handle_folder_contents_requested(&self, token: u32, folder: String) {
let requester_key = self.peer_username();
if let Err(e) =
self.client_channel
.send(ClientOperation::FolderContentsRequested {
requester_key,
token,
folder,
})
{
error!("[peer_actor] forward FolderContentsRequested: {}", e);
}
}
fn handle_share_list_received(&self, directories: Vec<SharedDirectory>) {
let username = self.peer_username();
if let Err(e) =
self.client_channel.send(ClientOperation::BrowseResult {
username,
directories,
})
{
error!("[peer_actor] forward BrowseResult: {}", e);
fn forward(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[peer_actor] forward to client: {}", e);
}
}
+4
View File
@@ -292,6 +292,10 @@ pub enum ClientOperation {
ShareListRequested {
requester_key: String,
},
/// A peer asked what we say about ourselves; send a UserInfoResponse.
UserInfoRequested {
requester_key: String,
},
/// A peer asked for one folder of our shares; send a FolderContentsResponse.
FolderContentsRequested {
requester_key: String,
+13
View File
@@ -766,6 +766,19 @@ impl Client {
},
);
}
ClientOperation::UserInfoRequested { requester_key } => {
Self::reply_to_peer(
&client_context,
&requester_key,
|ctx| {
crate::message::peer::build_user_info(
ctx.upload_slots as u32,
ctx.upload_queue.len() as u32,
ctx.has_free_upload_slot(),
)
},
);
}
ClientOperation::FolderContentsRequested {
requester_key,
token,
+2
View File
@@ -10,6 +10,7 @@ mod transfer_request;
mod transfer_response;
mod upload_denied;
mod upload_failed;
mod user_info;
// Re-export handlers
pub use file_search_response::{
@@ -31,3 +32,4 @@ pub use transfer_request::TransferRequest;
pub use transfer_response::TransferResponse;
pub use upload_denied::UploadDeniedHandler;
pub use upload_failed::UploadFailedHandler;
pub use user_info::{UserInfoRequest, build_user_info};
@@ -0,0 +1,33 @@
//! `UserInfoRequest` (peer code 15) and its reply (code 16): what a peer says
//! about itself when another user opens its profile.
use crate::message::{Message, MessageHandler};
use crate::peer::PeerMessage;
use std::sync::mpsc::Sender;
pub struct UserInfoRequest;
impl MessageHandler<PeerMessage> for UserInfoRequest {
fn get_code(&self) -> u32 {
15
}
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
let _ = sender.send(PeerMessage::UserInfoRequested);
}
}
/// Build a `UserInfoResponse` (peer code 16) with no description or picture.
#[must_use]
pub fn build_user_info(
upload_slots: u32,
queue_size: u32,
slots_free: bool,
) -> Message {
Message::new()
.write_int32(16)
.write_string("")
.write_bool(false)
.write_int32(upload_slots)
.write_int32(queue_size)
.write_bool(slots_free)
.clone()
}
+49
View File
@@ -3247,3 +3247,52 @@ fn a_third_party_client_fetches_one_folder_of_our_shares() {
let _ = std::fs::remove_dir_all(share_dir);
}
// "User info" in SoulseekQt and Nicotine+ asks the peer directly (peer code
// 15); a peer that never answers shows an endless spinner.
#[test]
fn a_third_party_client_reads_our_user_info() {
let server = server_or_skip!();
let share_dir = unique_download_dir();
std::fs::write(share_dir.join("probe.flac"), b"xxxx").unwrap();
let sharer_port = free_port().expect("sharer port");
let mut sharer = Client::with_settings(ClientSettings {
shared_directories: vec![share_dir.display().to_string()],
..server.listening_settings("e2e_info_sharer", "pw", sharer_port)
});
sharer.connect().expect("sharer connect");
assert!(sharer.login().expect("sharer login"));
sharer.set_upload_slots(3);
let server_addr = format!("{}:{}", server.host, server.port);
let _qt = login_raw(&server_addr, "e2e_info_asker", "pw")
.expect("third-party client logs in");
let mut p = connect_retry(
&format!("127.0.0.1:{sharer_port}"),
Duration::from_secs(5),
)
.expect("dial the sharer");
p.set_read_timeout(Some(Duration::from_secs(15))).unwrap();
p.write_all(&peer_init_bytes("e2e_info_asker", "P", 0))
.unwrap();
p.write_all(&Message::new().write_int32(15).get_buffer())
.unwrap();
p.flush().unwrap();
let mut reply = expect_code(&mut p, 16, Duration::from_secs(15))
.expect("the sharer answers with a UserInfoResponse");
reply.set_pointer(8);
let description = reply.read_string();
let has_picture = reply.read_bool();
let upload_slots = reply.read_int32();
let queue_size = reply.read_int32();
let slots_free = reply.read_bool();
assert!(description.is_empty(), "no description is configured");
assert!(!has_picture);
assert_eq!(upload_slots, 3, "the configured slot count");
assert_eq!(queue_size, 0, "nobody is waiting");
assert!(slots_free, "nothing is uploading");
let _ = std::fs::remove_dir_all(share_dir);
}