Skip to main content
~/projects / tocli

tocli

maintained

A terminal BitTorrent client with a process-per-torrent architecture, where every command works as a scriptable one-shot invocation or an interactive TUI.

tech = [Go, Cobra, Bubble Tea]
updated =

tocli is a terminal BitTorrent client built around one rule: if a command has everything it needs to act, it just acts, no prompts, safe to script or cron; if it’s missing something, it opens the relevant interactive screen instead of printing a usage error. tocli start ./file.torrent downloads immediately; tocli start with no source opens a screen to type one in. tocli list on a real terminal opens a live, refreshing dashboard; piped or --plain/--json output stays static and scriptable.

One process per torrent, no daemon

There’s no long-running tocli server. start/resume spawn one detached background process per torrent, which owns that torrent’s download for its entire life. pause is just SIGTERM to that process’s pid. There’s no IPC between the CLI and the background processes; they only communicate through files on disk under ~/.tocli/. A crash in one torrent’s process can’t touch any other torrent, and nothing keeps running when nothing is downloading.

Trusting on-disk state after a crash

A background process can die with no chance to update its own bookkeeping: kill -9, an OOM kill, the machine losing power. So tocli never fully trusts a stale status: running. It cross-checks the recorded pid against a real, live process, and compares a recorded boot-session id against the current one, so a pid reused by an unrelated process after a reboot is never mistaken for the original torrent. Each background process also holds an OS advisory lock (flock) on its torrent’s lock file for as long as it’s alive, released automatically the instant its file descriptors close, for any reason. That’s a direct kernel guarantee rather than an inference, so a second process can never start downloading into the same directory while another one already is, even in the narrow window right after a crash.

One listen port for every torrent, silently

Every internal/engine.Run process pinned its BitTorrent listen port to the same configured value, so only the first concurrently-running torrent could actually bind it. Every torrent after it silently fell back to outbound-only connections (no inbound peers), with no symptom anywhere in list or the logs, and it defeated any port-forwarding a user had set up on their router for that range.

The fix is internal/portpool: each torrent claims the lowest available port in the configured range before its torrent.Client is created, so a forwarded range fills predictably from the bottom up rather than landing on an arbitrary free port. Range exhaustion doesn’t fail the torrent; it falls back to an OS-assigned ephemeral port, and that fallback is now surfaced where a user would actually see it, list/list --json and the dashboard’s status column, rather than only in log.txt. A claimed port releases through the same paths that already release the per-torrent lock: normal completion, the pause handler, and deferred cleanup on an early failure.

A follow-up bug slipped through that first fix. When a torrent fell back to an “OS-assigned” port (the range was exhausted, or no range was configured at all), it was actually still landing on the underlying torrent library’s hardcoded default port, not a real OS-assigned one — the exact collision the pool was built to prevent, now showing up as a background process that crashed with the real error buried only in log.txt. The fix resets the listen port to 0 by default and only overrides it with a claimed pool port, so “OS-assigned” is the actual default rather than something every code path has to remember to set.

One engine, two front ends

The interactive TUI never talks to the torrent client or the on-disk state directly. Every screen calls the exact same internal/engine functions (StartTorrent, PauseTorrent, ResumeTorrent, RemoveTorrent) that the plain CLI commands call, so there’s exactly one implementation of what each action does. The CLI and TUI only differ in when they call it and how they render the result.

Built on anacrolix/torrent for the BitTorrent protocol, cobra for the command layer, and bubbletea for the TUI. Tagged releases are cross-compiled for Linux and macOS (amd64/arm64) via GitHub Actions.