Перейти к содержанию

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 пробует разжать; если это не удаётся или возвращаются пустые байты, используются сырые данные.

Порядок разбора:

  1. username (str)
  2. token (u32) — совпадает с token из запроса FileSearch
  3. num_results (u32) — количество записей файлов
  4. Для каждого файла: attr(u8=1), filepath(str), size(u64), ext(str), num_attrs(u32), затем для каждого атрибута: type(u32) + value(u32). Тип атрибута 0 = bitrate, тип 1 = длительность.
  5. После всех файлов: 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'ов, которые могут не совпадать:

  1. Инициатор передачи выбирает xfer token (из next_token()) и отправляет TransferRequest peer через P-соединение.
  2. Сервер 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, который разблокирует задачу передачи.