Skip to content

About

A small BitTorrent engine in Rust, built to move bytes fast.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Undercurrent

A small BitTorrent engine in Rust, built to move bytes fast.

Undercurrent downloads and seeds torrents. It's a library with a command-line tool, just under 8,000 lines of Rust, and three dependencies (libc, mio, sha1, plus rustls for HTTPS trackers).

  • Fast. On an M4 Mac it downloads 1.8× faster than libtorrent (1.4× at a 100 ms round trip) and seeds 1.6× faster than qBittorrent (2.9× with encryption), against seven other engines. On an 8-core ARM board it seeds 2.1× faster than qBittorrent per core.
  • Light. Seeding takes a few MB of memory; downloading, a few tens. The page cache is the only cache.
  • Complete. DHT, PEX, magnets, trackers (HTTP, HTTPS, UDP), web seeds (HTTP mirrors), encryption, resume without re-hashing, seed mode, smart ban, rate limits, port mapping, IPv6, creating torrents.
  • In your control. Look at a torrent's files before any data moves, pause and resume without losing work, and pin every socket to a VPN with a killswitch that fails closed.

Install

cargo install --git https://github.com/Benzo-Fury/Undercurrent undercurrent   # the command-line tool
cargo add undercurrent --git https://github.com/Benzo-Fury/Undercurrent        # the library

Rust 1.85 or newer. Linux and macOS. Build with --no-default-features to leave out HTTPS trackers (and rustls).

Quick start

From the command line:

undercurrent get ubuntu.torrent -o ~/Downloads          # download, then exit
undercurrent get 'magnet:?xt=urn:btih:…' --seed --ratio 2
undercurrent seed ubuntu.torrent ~/Downloads --seed-mode # seed files you already have
undercurrent create ./album -o album.torrent --tracker udp://tracker.example:1337/announce
undercurrent info ubuntu.torrent

As a library:

use undercurrent::{AddOptions, Config, Engine, EventKind, Source};

let engine = Engine::new(Config::default())?;
let events = engine.subscribe();
let bytes = std::fs::read("ubuntu.torrent")?;
let torrent = engine.add(Source::Torrent(&bytes), AddOptions { dir: "/downloads".into(), ..Default::default() })?;

for event in events {
    if event.torrent == Some(torrent.info_hash()) && event.kind == EventKind::Done {
        println!("{} is complete", torrent.name());
        break;
    }
}

The engine runs on its own threads; every method returns at once. Events arrive on any number of subscribe() channels.

How fast is it?

Measured on a MacBook Air (Apple M4) against libtorrent 2.1, qBittorrent 5.0, Transmission 4.1, rqbit 9, aria2 1.37, Tiny Torrent and WebTorrent, each in its own process, back to back. The full tables, methods and caveats are in Benchmarks; the suite is in bench/.

Downloading a 4 GiB file: Undercurrent 17.0–17.4 Gbit/s, Tiny Torrent 10.9–11.8, libtorrent 9.4–9.6, qBittorrent 7.5–8.1, WebTorrent 2.8, rqbit 1.5–4.6, aria2 2.0–2.1
Every download result: many files, a 100 ms round trip, CPU and memory

From four seeders on the same machine (Transmission can't take part: it refuses loopback peers from a tracker):

Engine 4 GiB file 2,000 files, 1 GiB 100 ms round trip CPU per GB (4 GiB) Memory (4 GiB)
Undercurrent 17.0–17.4 Gbit/s 27.5 Gbit/s 5.3 Gbit/s 1.03 s 53 MB
Tiny Torrent 10.9–11.8 Gbit/s 9.2 Gbit/s 1.9 Gbit/s 1.02 s 780 MB
libtorrent 9.4–9.6 Gbit/s 5.6 Gbit/s 3.9 Gbit/s 1.12 s 195 MB
qBittorrent 7.5–8.1 Gbit/s 2.8 Gbit/s 2.9 Gbit/s 1.33 s 904 MB
rqbit 1.5–4.6 Gbit/s 8.6 Gbit/s 0.6 Gbit/s 5.63 s 18 MB
WebTorrent 2.8 Gbit/s 1.3 Gbit/s 1.6 Gbit/s 3.76 s 649 MB
aria2 2.0–2.1 Gbit/s 1.5 Gbit/s 0.9 Gbit/s 2.69 s 18 MB
Seeding to 8 downloaders: Undercurrent 88–92 Gbit/s, Tiny Torrent 65–67, qBittorrent 57–58, libtorrent 51, rqbit 41, Transmission 32–34, aria2 32, WebTorrent 15
Every seeding result: encryption, CPU and memory

2 GiB to 8 lean downloaders that throw the data away, so only the seeder is measured:

Engine Plaintext RC4 CPU per GB (plaintext) Memory
Undercurrent 88–92 Gbit/s 25.2–25.9 Gbit/s 0.32 s 6 MB
Tiny Torrent 65–67 Gbit/s 8.7 Gbit/s 0.25 s 332 MB
qBittorrent 57–58 Gbit/s 8.8 Gbit/s 0.32 s 842 MB
libtorrent 51 Gbit/s 8.2 Gbit/s 0.60 s 35 MB
rqbit 41 Gbit/s no encryption 1.31 s 19 MB
Transmission 32–34 Gbit/s 3.4 Gbit/s 0.21 s 52 MB
aria2 32 Gbit/s 7.7 Gbit/s 0.25 s 25 MB
WebTorrent 15 Gbit/s 5.9 Gbit/s 0.94 s 408 MB
Also measured: 64 downloaders, hashing, idle cost, the DHT and a real swarm
  • 64 downloaders: 87 Gbit/s, against 53–54 for qBittorrent and Tiny Torrent (late in the full suite it sometimes drops to 55–60, for a reason not yet found; details).
  • Starting to seed: Undercurrent hashes 4 GiB at 15.2 GB/s; next is Tiny Torrent at 5.9.
  • Idle cost: under 0.33 ms of CPU per second (the measurement's smallest step), in 5 MB.
  • Peers through the DHT alone (no trackers, cold start): the first after 3–5 s and about 190 within the minute, level with libtorrent, qBittorrent and rqbit.
  • A real swarm and HTTP mirrors (the Debian DVD and Arch Linux's 468 mirrors, on a 100 Mbit/s home line): every engine was held to the line, which drifted between 8.5 and 102 Mbit/s over the runs. Undercurrent led some rounds and trailed others, like everyone, and used the least or near-least CPU per GB in every round.
Where it isn't first, and why
  • Memory while downloading (53 MB against 18 for aria2 and rqbit; 275–334 MB in a public swarm, capped at 512 MB). Undercurrent holds each piece in memory until it's complete, hashes it there and writes it once. rqbit writes every block and reads the piece back to hash it, at 5.5× the CPU per byte. aria2 caps its cache, at an eighth of the speed.
  • CPU per byte when seeding flat out (0.32 s/GB against 0.21–0.25 for Transmission, aria2 and Tiny Torrent). They run on one thread and stop at 32–67 Gbit/s. Undercurrent uses four to reach 88–92, and cores contend in the kernel. On one worker it's the cheapest engine measured (0.16 s/GB).
  • Speed on a slow line. At 100 Mbit/s the line decides, not the engine; what Undercurrent saves there is CPU.

More in Where it isn't first, and why.

On an 8-core ARM board, measured earlier, it seeded 2.1× faster than qBittorrent per core, 3.6× on the whole board, and downloaded 2.5× faster (details).

Why:

  • A download lands straight in its piece buffer and is hashed there with the CPU's SHA-1 instructions.
  • Every core works: libtorrent runs all peer traffic and encryption on one network thread.
  • On Linux, a plaintext upload goes from the page cache to the socket with sendfile, never entering user space.

Will it make my downloads faster?

Probably not. The speeds above are measured on one machine with no network in the way, and they're far beyond most internet connections. The world's median home connection is about 125 Mbit/s (Speedtest, May 2026), and every engine above can fill that many times over.

On a log scale: the median home line at 125 Mbit/s, qBittorrent at 7,500–8,100 Mbit/s and Undercurrent at 17,000–17,400 Mbit/s

When a torrent is slow, the cause is usually outside the engine:

  • Not enough seeders. A download can't go faster than the people uploading it.
  • Your connection. If it's already full, no engine can go faster.
  • Closed ports or carrier-grade NAT. Fewer peers can reach you.
  • A VPN that caps your speed.

On a 100 Mbit/s home line, every engine we tested was held to the line. What Undercurrent saved there was CPU. It's faster where the engine is the limit: very fast connections, slow or busy computers, torrents with thousands of small files, far-away peers, and seedboxes or servers seeding at high speed.

Recipes

Choose files, resume, seed, limit bandwidth, bind to a VPN, and more

Choose files before downloading

let t = engine.add(Source::Magnet(uri), AddOptions { dir, start: Start::MetadataOnly, ..Default::default() })?;
// …wait for EventKind::Metadata, then look at t.files()
let videos: Vec<usize> = t.files().iter().filter(|f| f.path.extension().is_some_and(|e| e == "mkv")).map(|f| f.index).collect();
if videos.is_empty() { t.close(true) } else { t.select(&videos) }

Nothing is requested from peers until select(), and files you don't select are never created.

Survive restarts without re-hashing

let engine = Engine::new(Config { resume_dir: Some("/data/resume".into()), ..Default::default() })?;

Resume state records the verified pieces, file sizes and modification times, totals and seed time. If every selected file still matches, nothing is hashed on restart.

Seed

let engine = Engine::new(Config { seed: SeedPolicy { enabled: true, ratio: 2.0, minutes: 7 * 24 * 60 }, ..Default::default() })?;

A torrent keeps serving once complete until it reaches the ratio or the time, whichever is first (0 means no limit), then emits Seeded. Override it per torrent with AddOptions::seed.

Seed files you already have

let bytes = undercurrent::create(Path::new("/data/album"), &CreateOptions { trackers: vec![vec![tracker.into()]], ..Default::default() })?;
engine.add(Source::Torrent(&bytes), AddOptions { dir: "/data".into(), seed_mode: true, ..Default::default() })?;

Seed mode trusts complete files and starts serving at once; each piece is hashed the first time it's uploaded. If one fails, the torrent re-checks everything and downloads what's missing. create hashes on every core.

Limit bandwidth

let engine = Engine::new(Config { limits: Limits { upload: 5_000_000, download: 20_000_000 }, ..Default::default() })?;
torrent.set_limits(Limits { upload: 1_000_000, download: 0 });   // per torrent, 0 = unlimited
engine.set_limits(Limits::default());                           // change at runtime

Bind to a VPN (optional)

let vpn = vpn.clone();
let engine = Engine::new(Config {
    bind: Some(Arc::new(move || vpn.local_address())),   // None while the VPN is down
    encryption: Encryption::PreferPlaintext,             // the VPN already encrypts
    ..Default::default()
})?;
on_vpn_down(|| engine.disconnect());   // every socket closes; files and progress are kept
on_vpn_up(|| engine.reconnect());

Every peer connection, tracker request, DHT packet and port-mapping request binds to the address the function returns, asked again for each new socket. While it returns None, nothing connects.

Other things you can do

  • Pause and resume: torrent.pause() and torrent.resume() keep files and progress. Add a torrent paused with paused: true.
  • Re-check the files: torrent.recheck() hashes what's on disk again, then fetches what's missing.
  • Download only: upload_slots: 0 never uploads.
  • Block peers: allow_peer: Some(Arc::new(|ip| !blocklist.contains(&ip))).
  • Web seeds: HTTP mirrors a torrent lists (url-list, or a magnet's ws=) are downloaded from beside peers, Config::web_seeds at a time per torrent (3; 0 turns them off). Add more with AddOptions::web_seeds; torrent.stats().web_seeds shows each one's rate and errors.
  • Restart the DHT warm: save engine.dht_nodes() and pass them back as Config::dht_nodes.
  • Diagnostics: torrent.stats() has every peer's client, rates, round trip and choke state, the trackers' replies, why connections closed, wasted bytes and hash failures.

How it works

Threads. One worker thread per core (per performance core on Apple silicon) owns its connections outright: reading, writing, handshakes and timers never cross threads. A worker with no connections sleeps. A control thread runs the listener, the DHT, port mapping and a 250 ms tick for every torrent: choking, dialling, peer exchange, trackers, seed limits and resume saves. Torrent state shared by the workers sits behind one short lock, taken once per batch of blocks, never per byte.

Downloading. Rarest first, partial pieces first, endgame for the last blocks, with request queues sized to each peer's rate and round trip and grown by a slow start. A readv puts each block's payload straight into its piece buffer, so payload is never copied in user space. A complete piece is hashed where it lies (with the CPU's SHA-1 instructions where it has them) and written with one pwrite. A peer that sends bad data is banned, even when several peers contributed to the piece.

Seeding. For a plaintext peer, blocks go out with sendfile from the page cache, so payload never enters user space. For an RC4 peer, blocks are read behind their headers, encrypted in place, and written 128 KiB at a time, on whichever core owns that peer. While seeding, upload slots grow with demand and take turns every 20 s, so every downloader is served within a second.

A torrent's life: Metadata (a magnet fetching its file list) → Waiting (only with Start::MetadataOnly) → Checking → Downloading → Seeding (if enabled) → Done. Plus Paused and Closed.

Encryption (Config::encryption): Prefer (default) encrypts outgoing connections and accepts both; PreferPlaintext encrypts only for peers that insist, and is cheapest; Require and Off are strict.

The in-depth reference is in docs/.

What's supported

Protocol BEP 3, fast extension (6), extension protocol (10), upload_only (21)
Finding peers Trackers: HTTP, HTTPS and UDP (15), compact lists (23), tiers (12, announced in parallel); DHT (5) and IPv6 DHT (32); PEX (11); magnet x.pe
Torrents v1, magnets (9) with so= (53) and ws=, private (27), padding files (47), hybrid v1+v2 through their v1 half
Web seeds HTTP and HTTPS mirrors (url-list, 19), with kept-alive range requests and redirects
Network IPv4 and IPv6, MSE/PE encryption, NAT-PMP and UPnP port mapping, binding to one address

Left out: uTP and hole punching (55), pure v2 torrents, local peer discovery, and file priorities. Why not uTP. Queueing, RPC and blocklist formats belong to the application.

Development

cargo test                       # unit and end-to-end tests (about 30 s)
cargo clippy --all-targets
sh conformance/run.sh            # Docker: against libtorrent and Transmission, both directions
bun bench/cli.ts suite          # benchmarks against seven other engines (bench/README.md)

tests/size.rs keeps the engine within 8,000 lines of code. More in docs/DEVELOPMENT.md.

License

Apache License 2.0. See LICENSE.

About

A small BitTorrent engine in Rust, built to move bytes fast.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages