Skip to content

File Transfer

SoulSeek file transfers are implemented in src-tauri/src/soulseek/transfer.rs. Every audio playback starts by negotiating a transfer with a peer, downloading the file to a temp path, and serving it over a local HTTP server with Range request support.

Source: src-tauri/src/soulseek/transfer.rs


Constants

const CONNECT_TIMEOUT_SECS: u64 = 8;      // P-connection TCP connect
const TRANSFER_TIMEOUT_SECS: u64 = 120;   // waiting for TransferRequest or F connection
const HTTP_CHUNK: usize = 32 * 1024;      // read/write granularity for HTTP serving

StreamHandle

The output of a successful download_and_stream call:

pub struct StreamHandle {
    pub url: String,               // "http://127.0.0.1:<port>/slsk/<token>"
    pub token: u32,                // release key
    pub temp_path: PathBuf,        // OS temp dir, e.g. neegde_slsk_42.tmp
    pub total_size: u64,           // bytes as declared by peer
    pub downloaded: Arc<AtomicU64>,// running byte count, updated by download_loop
    pub complete: Arc<AtomicBool>, // set when download_loop exits
    pub download_abort: AbortHandle,
    pub http_abort: AbortHandle,
}

downloaded and complete are shared between the download task and the HTTP server task so the server can serve data as it arrives without blocking until the full file is downloaded.


Handshake: two paths

SoulSeek has two protocol variants for requesting files. download_and_stream tries modern first, falls back to legacy on failure.

Modern path: QueueUpload (code 43)

Used by SoulseekQt and Nicotine+.

us                               peer
 |                                |
 |──── PeerInit (code 1) ────────>|
 |──── QueueUpload (code 43) ────>|  filepath
 |<─── TransferRequest (code 40) ─|  direction=1, xfer_token, size
 |──── TransferResponse (code 41) >|  xfer_token, allowed=true, size
 |                                |
 |<═══ F connection ══════════════|  TCP stream to xfer_token slot
 |<─── FileTransferInit (4 bytes)─|  raw u32 token (not framed)
 |──── FileOffset (8 bytes) ─────>|  u64 = 0 (start from beginning)
 |<═══ file data ═════════════════|  raw bytes, no further framing

After QueueUpload, the peer may send intermediate messages before TransferRequest:

  • PlaceInQueue (code 44): file is queued on the peer — skip and wait.
  • UploadDenied (code 50): peer refuses — propagate as error, let the retry in download_and_stream try legacy.
  • TransferResponse (code 41) received before TransferRequest: legacy peer, abort modern path.

Legacy path: TransferRequest (code 40)

Older clients expect us to send the request:

us                               peer
 |                                |
 |──── PeerInit (code 1) ────────>|
 |──── TransferRequest (40) ─────>|  direction=0 (download), token, filepath
 |<─── TransferResponse (41) ─────|  token, allowed=bool, size
 |                                |
 |<═══ F connection ══════════════|  same token routing
 |<─── FileTransferInit (4 bytes)─|
 |──── FileOffset (8 bytes) ─────>|
 |<═══ file data ══════════════════

P-connection setup

Before any transfer negotiation, an outbound P-type TCP connection is established to the peer:

  1. get_peer_addr(username) — sends GetPeerAddress (code 3) to the server, waits up to 5 seconds for the response.
  2. TcpStream::connect((ip, port)) with an 8-second timeout.
  3. send_peer_init — sends the PeerInit handshake:
// PeerInit wire format:
// [u32 length][u8 code=1][str username][u32 type_len=1][u8 'P'][u32 token=0]

Note: PeerInit uses a 1-byte code, not a 4-byte u32.


F-connection and FileTransferInit

The file data flows on a separate F-type TCP connection (see Session for how F-connections are routed). After registering a waiter (register_f_waiter) and sending TransferResponse, the code waits up to TRANSFER_TIMEOUT_SECS for the F socket to arrive.

Once the F socket arrives:

  1. Read 4 bytes of FileTransferInit (raw u32 token, not length-framed). If it was already consumed during the F-connection handshake (stored in FConnReady.file_transfer_init_consumed), skip the read.
  2. Send FileOffset — 8 bytes, u64 little-endian, value 0. This tells the peer where to start.
  3. Begin receiving raw file bytes.

The write half of the F socket must be kept open while downloading. Dropping it after sending FileOffset sends a TCP FIN, and many peers interpret this as the download being cancelled — they stop uploading or close the connection entirely. The write half (_wh) is held alive in download_loop as an ignored parameter until the download completes.


Download loop

async fn download_loop(
    mut rh: OwnedReadHalf,
    _wh: OwnedWriteHalf,  // must not be dropped until done
    temp_path: PathBuf,
    downloaded: Arc<AtomicU64>,
    complete: Arc<AtomicBool>,
) {
    let mut file = tokio::fs::File::create(&temp_path).await?;
    let mut buf = vec![0u8; HTTP_CHUNK]; // 32 KB
    loop {
        match rh.read(&mut buf).await {
            Ok(0) => break,
            Ok(n) => {
                file.write_all(&buf[..n]).await?;
                downloaded.fetch_add(n as u64, Ordering::Release);
            }
            Err(_) => break,
        }
    }
    complete.store(true, Ordering::Release);
}

downloaded is updated with Ordering::Release after each write completes. The HTTP server reads downloaded with Ordering::Acquire, so it never reads a byte range that hasn't been written to disk yet.

The temp file is named neegde_slsk_<token>.tmp in the OS temp directory. It is deleted when soulseek_release_stream is called (abort + remove_file).


Local HTTP server

run_download_pipeline binds a TcpListener on 127.0.0.1:0 (random ephemeral port) and spawns http_server_loop. The URL http://127.0.0.1:<port>/slsk/<token> is returned to the frontend.

Range request handling

The HTTP server implements Range requests so the audio element can seek:

  • Parses Range: bytes=start-end header.
  • Waits in a 80 ms polling loop until downloaded > range_start (or complete).
  • Then reads the requested range from the temp file using spawn_blocking + std::fs::File::seek.
  • Sends 206 Partial Content or 200 OK headers as appropriate.
  • Streams chunks in a loop, re-reading from disk each iteration until range_end is reached.

If the requested range is not yet available and 60 seconds pass, it returns 503 Service Unavailable.

HTTP/1.1 206 Partial Content
Content-Type: audio/flac
Content-Range: bytes 0-32767/12345678
Content-Length: 32768
Accept-Ranges: bytes
Access-Control-Allow-Origin: *

CORS headers are required because the dev WebView (localhost) and the HTTP server (127.0.0.1) are different origins.

MIME type mapping

File extension → Content-Type:

Extension MIME
mp3 audio/mpeg
flac audio/flac
ogg audio/ogg
m4a audio/mp4
wav audio/wav
aac audio/aac
opus audio/opus
wma audio/x-ms-wma
ape audio/x-monkeys-audio

Cover preview download

download_cover_preview uses the same handshake as download_and_stream but reads at most 512 KB into memory instead of saving to a temp file and starting an HTTP server. This is used by soulseek_cover_preview to fetch album art from a peer.

The cover bytes are returned to the Tauri command handler, which base64-encodes them and writes the result to soulseek/covers/<md5_of_key>.json. Subsequent requests for the same (username, filepath) pair are served from the disk cache without any P2P traffic.


P-connection drain

After sending TransferResponse, the write half of the P connection is dropped (closed). The read half is kept alive in a drain task that reads and discards until EOF. This is necessary because some peers keep the P connection open and continue sending messages (PlaceInQueue updates, PeerInit echo) while setting up the F connection. If the read half were dropped, the OS would send TCP RST, which can interrupt the F connection setup on the peer side.