spill / docs
Architecture
This page is for curious users and contributors. It describes how Spill is put together, not every detail; the code is the reference. The Rust API reference documents the library.
Package and modules
Spill is one Cargo package, published as spill, with a library and a binary:
src/protocol wire formats only: bencode, metainfo, magnets, peer messages,
tracker and DHT messages, the resume format. No I/O.
src/engine the BitTorrent engine: sessions, torrents, peers, discovery,
storage.
src/daemon the `spill` binary: config, SQLite catalog, supervisor,
control socket, CLI and web interface.
deploy example configs, systemd units and a launchd agent.
The library exposes spill::protocol and spill::engine; the binary starts at
src/daemon/main.rs. protocol knows nothing about sockets or files, engine
knows nothing about the catalog or the CLI, and daemon only uses the engine’s
public API. Engine internals are restricted to the engine module.
Codec tests live in tests/protocol/, and daemon end-to-end scenarios are
grouped by behaviour in tests/daemon/, with shared harness code in
tests/common/. tests/protocol.rs and tests/daemon.rs are the test entry
points. Run them with cargo test --test protocol and
cargo test --test daemon, or run both with cargo test --locked.
One process, one async thread
The daemon runs on a single-threaded Tokio runtime. Every network operation is asynchronous on that thread; anything that blocks (file reads and writes, SHA-1 hashing) runs on Tokio’s blocking thread pool.
spill / web page
│ JSON
┌──────────▼──────────┐
│ daemon: supervisor │── SQLite catalog (torrents, settings,
│ (reconcile ~1 s) │ accounting, resume data, DHT nodes)
└──────────┬──────────┘
│ admit / stop
┌──────────▼──────────┐
│ engine: Session │── TCP listener, UDP (DHT + trackers + uTP),
│ │ dial queue, bandwidth budget, DHT node
└───┬────────────┬────┘
│ │ one task per running torrent
┌───────▼───┐ ┌─────▼─────┐
│ torrent A │ │ torrent B │ …
└───────────┘ └───────────┘
The daemon
- The catalog (
catalog.sqlitein WAL mode) is the source of truth: each torrent’s metainfo or magnet, output directory, intent (run, paused, recheck, remove), settings, uploaded bytes, seeding time, resume data, the files Spill created, and errors with retry times. - The supervisor reconciles the catalog with what is running about once a second and immediately after any command. It starts torrents into free slots, stops paused ones, applies seed goals, schedules retries and records accounting.
- Control requests from the Unix socket and the web page go through the same in-process dispatcher.
The engine
Session
The session owns everything torrents share: the listening TCP socket (incoming
handshakes are routed to the right torrent by info hash), one UDP socket on the
same port (DHT traffic, tracker replies and uTP peer packets are demultiplexed
there), the uTP connections and timers, the outgoing connection queue, the global bandwidth budget,
the HTTP client for trackers and the DHT node. It also enforces the address
policy (allow_local_network) and the daemon-wide resource limits.
A torrent
Each running torrent is one task with three parts:
- Core: a deterministic state machine. It receives inputs (a peer message, a finished disk write, a timer tick) and returns actions (send a message, write a block, hash a piece, connect to an address). It does no I/O and never reads the clock itself. All BitTorrent decisions live here: piece picking (rarest first, file priorities, endgame), requests and timeouts, choking, extensions (BEP 6, 9, 10, 11), bans.
- Shell: performs the core’s actions on real peer sockets and storage and feeds results back as inputs.
- Discovery: trackers, DHT lookups, PEX contacts and web seeds produce addresses and connections for the shell.
Each peer connection runs as its own small task that reads and writes frames, with bounded queues so a slow peer can’t use unbounded memory.
Storage
Each torrent has one writer task between the core and the disk:
peer → shell → core ─write block─▶ writer ─pwrite─▶ files (*.part)
◀─written──── ─hash──▶ blocking pool
◀─verified / failed─
peer ◀─ shell ◀─ core ◀─block──── writer ◀─pread── files (upload cache)
- Blocks are written with positional writes straight to the destination files.
- A piece is hashed from memory when it’s still buffered, otherwise read back from disk. Only verified pieces are ever uploaded.
- Uploads are served through a small read-ahead cache.
- Files are renamed from
NAME.parttoNAMEwhen complete. - Resume data (verified pieces plus file fingerprints) is handed to the daemon every two minutes, at completion and at stop. Files are synced to disk only at completion.
Design rules
- Correctness first: only data that matches its piece hash is kept as verified or uploaded; remote input is validated at the boundary and a bad peer is disconnected, never allowed to fail the torrent.
- Bounded everything: queues, caches, connection counts and message sizes all have fixed limits.
- Joined shutdown: stopping a torrent or the daemon waits for every peer, disk and discovery task to finish before releasing files.
- Small and direct: concrete types over abstraction layers, end-to-end tests
over unit tests. See
AGENTS.mdfor the full contributor rules.