Session & Search¶
The SoulSeek session manages the TCP connection to server.slsknet.org:2242, dispatches file searches, coordinates peer connections, and routes inbound peer data. The whole stack lives in src-tauri/src/soulseek/session.rs.
Source: src-tauri/src/soulseek/session.rs
Constants¶
const SERVER_HOST: &str = "server.slsknet.org";
const SERVER_PORT: u16 = 2242;
const CLIENT_VERSION: u32 = 160;
const SEARCH_TIMEOUT_SECS: u64 = 12;
const PEER_ADDR_TIMEOUT_SECS: u64 = 5;
const MAX_SEARCH_RESULTS: usize = 500;
const CONNECT_TO_PEER_TCP_ATTEMPTS: u32 = 4;
const CONNECT_TO_PEER_TCP_TIMEOUT_SECS: u64 = 18;
SEARCH_TIMEOUT_SECS = 12 is the hard cap on how long a single search() call waits. The session is marked dead immediately on any write failure so searches that would otherwise run for the full 12 seconds fail fast.
Session struct¶
pub struct Session {
pub username: String,
pub listen_port: u16,
writer: Mutex<OwnedWriteHalf>, // serialises writes to the server socket
pub pending_searches: Arc<DashMap<u32, mpsc::UnboundedSender<Vec<SlskFileResult>>>>,
pub pending_peer_addr: Arc<DashMap<String, oneshot::Sender<(Ipv4Addr, u16)>>>,
pub pending_f_conns: Arc<DashMap<u32, Arc<FConnSlot>>>,
pending_f_peer_order: Arc<DashMap<String, VecDeque<u32>>>,
pub debug_log: Arc<AppDebugLog>,
dead: AtomicBool,
app_handle: AppHandle,
}
| Field | Purpose |
|---|---|
pending_searches |
Maps a FileSearch token → unbounded channel sender. The server reader delivers batches here; search() drains the receiver. |
pending_peer_addr |
Maps username → oneshot. Resolved by GetPeerAddress responses from the server. |
pending_f_conns |
Maps a transfer token → FConnSlot. Resolved when an F-type TCP stream arrives from a peer. |
pending_f_peer_order |
FIFO queue of xfer tokens per peer (lowercase username). Used to match server-assigned ConnectToPeer tokens to the right waiting transfer. |
dead |
AtomicBool. Set on first write failure or socket read error. Checked by is_dead() so callers don't retry on a broken socket. |
The Session is held in an Arc. Background tasks hold a Weak<Session> so they exit cleanly when the strong count drops to zero (i.e., when the frontend calls logout and the SoulSeekState replaces the session Arc).
Login and setup: Session::connect¶
TcpStream::connect("server.slsknet.org:2242")
│
├── send Login (code 1):
│ username, password, CLIENT_VERSION=160, md5(username+password), minor_ver=1
│
├── recv login response (code 1): success bool + optional reject reason
│
├── TcpListener::bind("0.0.0.0:0") ← random ephemeral port
│
├── send SetWaitPort (code 2): listen_port
│
├── send HaveNoParent (code 71, bool=true) ← opt out of distributed search
├── send SharedFolderFiles (code 35, dirs=0, files=0) ← announce as valid client
└── send CheckPrivileges (code 92)
The MD5 hash is computed over the concatenation of username and password (no separator) — md5_hex(&format!("{username}{password}")).
After login, two background tasks are spawned with Weak<Session> references:
server_reader_loop— reads and dispatches server messages on the existing TCP connection.peer_listener_loop— accepts inbound TCP connections from peers on the bound listen port.
Dead session detection¶
Any write error to the server socket calls mark_dead_and_notify:
pub fn mark_dead_and_notify(&self, reason: impl Into<String>) {
if self.dead.swap(true, Ordering::AcqRel) {
return; // already marked, emit event only once
}
// emit "soulseek-disconnected" Tauri event with username + reason
}
The atomic swap makes this idempotent — both the server reader and the peer listener can call it from their respective tasks without emitting duplicate events.
The SoulSeekState::get_session helper checks is_dead() before handing out the session to command handlers. If dead, it returns an error immediately rather than letting the command wait for a 12-second search timeout.
File search: Session::search¶
pub async fn search(&self, query: String, app: Option<AppHandle>, request_id: u64)
-> Vec<SlskFileResult>
{
let token = next_token();
let (tx, mut rx) = mpsc::unbounded_channel();
self.pending_searches.insert(token, tx);
// send FileSearch (code 26): token + query
let msg = Msg::new(26).u32(token).str(&query).build();
self.send_raw(msg).await?;
// collect batches until deadline or MAX_SEARCH_RESULTS
let deadline = Instant::now() + Duration::from_secs(SEARCH_TIMEOUT_SECS);
loop {
match timeout_at(deadline, rx.recv()).await {
Ok(Some(batch)) => {
if let Some(app) = &app {
app.emit("soulseek-search-batch", SlskSearchBatchEvent { request_id, rows });
}
results.extend(batch);
if results.len() >= MAX_SEARCH_RESULTS { break; }
}
_ => break,
}
}
self.pending_searches.remove(&token);
// dedup by (username, filepath.to_lowercase()), first occurrence wins
results.retain(|r| seen.insert(format!("{}|{}", r.username, r.filepath.to_lowercase())));
results
}
Each peer that has matching files sends a FileSearchResponse back through a P-type TCP connection. The server_reader_loop and peer_listener_loop funnel these responses into the unbounded channel. The search() loop drains the channel until the deadline or result cap, then removes the pending entry so stale responses are silently dropped.
The soulseek-search-batch Tauri event is emitted per-batch (not per-result) so the frontend can update incrementally without waiting for all 12 seconds. The request_id lets the frontend ignore batches from superseded searches.
Server reader loop¶
server_reader_loop runs in a spawned task, reading messages in a loop:
| Code | Message | Handling |
|---|---|---|
| 3 | GetPeerAddress response | Parses username, IP, port; completes the pending_peer_addr oneshot. IP bytes are read as a big-endian u32 (despite the rest of the protocol being LE) due to network byte order. |
| 18 | ConnectToPeer | Parses connection details (username, type, IP, port, token). For type "F", calls link_server_f_token. Spawns handle_server_connect_to_peer to make the outbound TCP connection. |
| 64,69,83–93,100,102,104 | Keepalive/housekeeping | Ignored silently. |
ConnectToPeer format ambiguity¶
The SoulSeek spec has two formats for ConnectToPeer (code 18):
- Format A (most clients):
token(u32), username(str), type(str), ip(u32), port(u32) - Format B (older):
username(str), type(str), ip(u32), port(u32), token(u32)
The parser tries Format A first, validates the resulting IP (rejects 0.0.0.0, loopback, multicast), and falls back to Format B if the IP is invalid.
Peer listener loop¶
peer_listener_loop accepts inbound TCP connections from peers and dispatches them:
- PierceFirewall (code 0) — peer responds to our
ConnectToPeer. The 4-byte token maps to apending_f_connsentry; the stream is delivered viadeliver_f_stream. - PeerInit (code 1) — peer initiates a connection.
conn_type = "P"→handle_p_connection;conn_type = "F"→deliver_f_stream.
P-connection: FileSearchResponse (code 9)¶
handle_p_connection reads messages from a P-type peer connection looking for FileSearchResponse:
The response body may be zlib-compressed. try_zlib_decompress attempts decompression; if it fails or returns empty bytes, the raw data is used directly.
Parse order:
username(str)token(u32) — matches the token from theFileSearchrequestnum_results(u32) — count of file entries- For each file:
attr(u8=1),filepath(str),size(u64),ext(str),num_attrs(u32), then for each attr:type(u32)+value(u32). Attribute type 0 = bitrate, type 1 = duration. - After all files:
slots_free(u8),avg_speed(u32),queue_length(u64)— per-peer stats stamped onto every row from this batch.
Only audio files and images ≥ 256 bytes are kept; zero-size entries are dropped.
SlskFileResult¶
pub struct SlskFileResult {
pub username: String,
pub filepath: String,
pub size: u64,
pub bitrate: Option<u32>,
pub duration: Option<u32>,
pub is_image: bool, // image rows carry cover art, not audio
pub slots_free: bool, // peer has at least one free upload slot
pub avg_speed: u32, // bytes/s declared by peer
pub queue_length: u64, // pending uploads ahead of us
}
slots_free, avg_speed, and queue_length come from the tail of the response (per-peer, not per-file) and are duplicated onto every row from that peer. They're used by the peer ranking logic when choosing which peer to request a file from.
F-connection token routing¶
File transfers use a separate F-type TCP connection. The flow involves two token spaces that may disagree:
- The transfer initiator chooses an xfer token (from
next_token()) and sends aTransferRequestto the peer over a P connection. - The peer's server may send a server ConnectToPeer with a different token (the server assigns its own token for the relay).
FConnSlot and pending_f_peer_order bridge these two token spaces:
// register_f_waiter: called before sending TransferRequest
// stores xfer_token → slot, appends xfer_token to FIFO for peer_username
fn register_f_waiter(&self, xfer_token: u32, peer_username: String) -> oneshot::Receiver<FConnReady>
// link_server_f_token: called when server ConnectToPeer arrives
// pops the oldest xfer_token from the FIFO for that peer and maps server_token → same slot
fn link_server_f_token(&self, server_token: u32, peer_username: &str)
When an F-type TCP stream arrives (either inbound PierceFirewall or outbound connection after server ConnectToPeer), deliver_f_stream sends it to the waiting oneshot::Receiver, which unblocks the transfer task.