Session и поиск¶
Session SoulSeek управляет TCP-соединением с server.slsknet.org:2242, диспетчеризует файловые поиски, координирует peer-соединения и маршрутизирует входящие данные от peer. Весь стек находится в src-tauri/src/soulseek/session.rs.
Источник: src-tauri/src/soulseek/session.rs
Константы¶
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 — жёсткий лимит на время ожидания одного вызова search(). Session немедленно помечается как dead при любой ошибке записи, поэтому поиски, которые иначе длились бы все 12 секунд, завершаются быстро.
Структура Session¶
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,
}
| Поле | Назначение |
|---|---|
pending_searches |
Отображает token FileSearch → отправитель unbounded-канала. Читатель сервера доставляет сюда батчи; search() вычитывает из получателя. |
pending_peer_addr |
Отображает имя пользователя → oneshot. Завершается при получении ответа GetPeerAddress от сервера. |
pending_f_conns |
Отображает transfer token → FConnSlot. Завершается при поступлении F-type TCP-потока от peer. |
pending_f_peer_order |
FIFO-очередь xfer token'ов на peer (имя пользователя в нижнем регистре). Используется для сопоставления назначенных сервером ConnectToPeer token'ов с правильной ожидающей передачей. |
dead |
AtomicBool. Устанавливается при первой ошибке записи или ошибке чтения socket. Проверяется is_dead(), чтобы вызывающий код не повторял попытки на сломанном socket. |
Session хранится в Arc. Фоновые задачи держат Weak<Session>, чтобы корректно завершаться, когда счётчик strong-ссылок падает до нуля (то есть когда фронтенд вызывает logout и SoulSeekState заменяет Arc session).
Логин и настройка: 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)
MD5-хеш вычисляется над конкатенацией имени пользователя и пароля (без разделителя) — md5_hex(&format!("{username}{password}")).
После логина запускаются две фоновые задачи со ссылками Weak<Session>:
server_reader_loop— читает и диспетчеризует сообщения сервера по существующему TCP-соединению.peer_listener_loop— принимает входящие TCP-соединения от peer на привязанном порту прослушивания.
Обнаружение мёртвой session¶
Любая ошибка записи в socket сервера вызывает 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
}
Атомарный swap делает функцию идемпотентной — и читатель сервера, и peer-слушатель могут вызвать её из своих задач без отправки дублирующих событий.
Вспомогательная функция SoulSeekState::get_session проверяет is_dead() перед тем как передать session обработчикам команд. Если session мёртва, немедленно возвращается ошибка, а не ожидание таймаута поиска длиной 12 секунд.
Файловый поиск: 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
}
Каждый peer, у которого есть подходящие файлы, отправляет FileSearchResponse через P-type TCP-соединение. server_reader_loop и peer_listener_loop направляют эти ответы в unbounded-канал. Цикл search() вычитывает канал до дедлайна или достижения лимита результатов, затем удаляет ожидающую запись, чтобы устаревшие ответы молча отбрасывались.
Событие Tauri soulseek-search-batch отправляется на батч (не на каждый результат), чтобы фронтенд мог обновляться инкрементально, не дожидаясь полных 12 секунд. request_id позволяет фронтенду игнорировать батчи от устаревших поисков.
Цикл чтения сервера¶
server_reader_loop выполняется в задаче spawn, читая сообщения в цикле:
| Код | Сообщение | Обработка |
|---|---|---|
| 3 | Ответ GetPeerAddress | Разбирает имя пользователя, IP, порт; завершает oneshot из pending_peer_addr. Байты IP читаются как big-endian u32 (несмотря на то что остальной протокол LE) из-за сетевого порядка байт. |
| 18 | ConnectToPeer | Разбирает детали соединения (имя пользователя, тип, IP, порт, token). Для типа "F" вызывает link_server_f_token. Запускает handle_server_connect_to_peer для выполнения исходящего TCP-соединения. |
| 64,69,83–93,100,102,104 | Keepalive/служебные | Молча игнорируются. |
Неоднозначность формата ConnectToPeer¶
Спецификация SoulSeek имеет два формата для ConnectToPeer (код 18):
- Формат A (большинство клиентов):
token(u32), username(str), type(str), ip(u32), port(u32) - Формат B (устаревший):
username(str), type(str), ip(u32), port(u32), token(u32)
Parser сначала пробует формат A, проверяет полученный IP (отвергает 0.0.0.0, loopback, multicast), и переключается на формат B при некорректном IP.
Цикл peer-слушателя¶
peer_listener_loop принимает входящие TCP-соединения от peer и диспетчеризует их:
- PierceFirewall (код 0) — peer отвечает на наш
ConnectToPeer. 4-байтовый token сопоставляется с записью вpending_f_conns; поток доставляется черезdeliver_f_stream. - PeerInit (код 1) — peer инициирует соединение.
conn_type = "P"→handle_p_connection;conn_type = "F"→deliver_f_stream.
P-соединение: FileSearchResponse (код 9)¶
handle_p_connection читает сообщения из P-type peer-соединения в поисках FileSearchResponse:
Тело ответа может быть сжато zlib. try_zlib_decompress пробует разжать; если это не удаётся или возвращаются пустые байты, используются сырые данные.
Порядок разбора:
username(str)token(u32) — совпадает с token из запросаFileSearchnum_results(u32) — количество записей файлов- Для каждого файла:
attr(u8=1),filepath(str),size(u64),ext(str),num_attrs(u32), затем для каждого атрибута:type(u32)+value(u32). Тип атрибута 0 = bitrate, тип 1 = длительность. - После всех файлов:
slots_free(u8),avg_speed(u32),queue_length(u64)— статистика на peer, записываемая в каждую строку из этого батча.
Сохраняются только аудиофайлы и изображения ≥ 256 байт; записи нулевого размера отбрасываются.
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 и queue_length берутся из хвоста ответа (на peer, не на файл) и дублируются в каждую строку от этого peer. Они используются логикой ранжирования peer при выборе источника для запроса файла.
Маршрутизация token'ов F-соединений¶
Передача файлов происходит через отдельное F-type TCP-соединение. Процесс включает два пространства token'ов, которые могут не совпадать:
- Инициатор передачи выбирает xfer token (из
next_token()) и отправляетTransferRequestpeer через P-соединение. - Сервер peer может отправить серверный ConnectToPeer с другим token (сервер назначает свой token для ретрансляции).
FConnSlot и pending_f_peer_order связывают эти два пространства token'ов:
// 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)
Когда F-type TCP-поток поступает (либо входящий PierceFirewall, либо исходящее соединение после серверного ConnectToPeer), deliver_f_stream отправляет его ожидающему oneshot::Receiver, который разблокирует задачу передачи.