A caller that gives up on a login had no way to reach the client: the thread that owns it sits in login() for up to 45 seconds, and disconnect() needs the client to itself. Until that thread returned, the abandoned client kept connecting and still wrote its login frame the moment the server finished the TCP handshake. When that happened after a replacement had logged in under the same name, the server kicked the replacement. Client::cancel_handle() hands out a CancelHandle that can be moved to any thread. After cancel(): - the server actor writes nothing more. The check and the write share a lock with cancel(), so once cancel() returns no frame leaves, whether it was waiting in the queue, in the dispatcher channel, or mid-flush; - login() returns NotConnected within 100 ms, and hands no login to the actor at all when the client was already cancelled; - connect() refuses before binding the listener when the client was already cancelled. A cancel that lands during that check can still let the bind through. Either way the owner drops the client as before, which disconnects it and gives its port back. A frame already written before cancel() returned cannot be recalled. The API is additive, and login() on a client nobody cancels behaves as before. Soulseek.NET tears the whole connection down when ConnectAsync is cancelled, and Nicotine+ drops outgoing messages for a socket it closed; this gives soulseek-rs the same end state from another thread.
soulseek-rs-lib
A Rust library for implementing the Soulseek peer-to-peer protocol.
Website: https://re-invention.nl/soulseek-rs/
About
This library provides the core functionality for interacting with the Soulseek network. It can be used to build custom Soulseek clients.
Usage
Add it to your Cargo.toml:
cargo add soulseek-rs-lib
Example
Simple Usage
use soulseek_rs::Client;
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create and connect to Soulseek server
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
// Search for files
let results = client.search("Alex Kassian lifestream", Duration::from_secs(10))?;
// Download first available file
if let Some(result) = results.iter().find(|r| !r.files.is_empty()) {
let file = &result.files[0];
let (_download, status) = client.download(
file.name.clone(),
file.username.clone(),
file.size,
"~/Downloads".to_string(),
)?;
// `download` returns as soon as the request is queued: the transfer is
// finished when the channel says so, not when this call returns.
for update in status {
println!("{}: {update:?}", file.name);
}
}
Ok(())
}
Advanced Configuration
use soulseek_rs::{Client, ClientSettings, PeerAddress};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create client with custom settings
let settings = ClientSettings {
server_address: PeerAddress::new("server.slsknet.org".to_string(), 2416),
enable_listen: true,
listen_port: 3000,
..ClientSettings::new("username", "password")
};
let mut client = Client::with_settings(settings);
client.connect()?;
client.login()?;
Ok(())
}
Asking about a peer
The server-backed lookups are request/poll pairs: you ask, then read the
answer once it lands. request_user_info clears any previous answer first, so
the loop below observes this request rather than the last one.
use soulseek_rs::Client;
use std::thread::sleep;
use std::time::{Duration, Instant};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = Client::new("username", "password");
client.connect()?;
client.login()?;
client.request_user_info("someuser")?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Some(info) = client.user_info("someuser") {
// Presence and stats are separate replies, so each is an Option:
// a reply that never arrived is None, not "offline" or "shares nothing".
println!("{:?} {:?}", info.presence, info.stats);
break;
}
sleep(Duration::from_millis(100));
}
// Room membership is kept current from join and leave events.
println!("{:?}", client.room_members("lobby"));
Ok(())
}
ClientOperation is #[non_exhaustive]: every protocol message the client
learns to handle adds a variant, so match it with a wildcard arm. SharedFile
is too, so build it with the .. syntax rather than listing every field.