Development¶
Layout¶
cmd/torrnado CLI entrypoint (cobra): daemon, add, remove, pause,
resume, recheck, priority, limit, move, list, and
the bare command (attach the TUI)
internal/engine anacrolix/torrent client wrapper: Go API + an
event channel of state snapshots. No IPC or UI
code in here.
internal/ipc gob-over-Unix-socket RPC: call/reply for commands,
server-pushed events for state -- what lets the
daemon run detached from any UI.
internal/tui bubbletea model: a three-pane layout (status
sidebar, torrent list, docked Pieces/Peers/Files
detail pane) plus the command palette. Talks to the
engine only through an ipc.Client, so it doesn't
matter whether the daemon is a spawned subprocess
or one you started yourself.
internal/config TOML config, XDG paths, validation.
internal/theme built-in themes + TOML theme overrides.
internal/batch expands add-command arguments (dirs, globs,
magnet-list files) into a flat source list.
internal/format byte/rate/ratio/ETA formatting shared by the TUI
and `torrnado list`.
Dependency order is strict and one-directional: format and engine have
no internal dependencies; ipc depends only on engine; config depends
on format; tui depends on engine, ipc, config, theme and
batch; cmd depends on everything. Nothing in engine or ipc imports
tui or cmd -- if you find yourself wanting to, the abstraction has
leaked.
Tasks¶
make # list every target
make build # build ./torrnado
make test # go test ./...
make test-race # the same with the race detector
make check # gofmt -l, go vet, go test -- the gate before a commit
make e2e # drive the built binary through the shell suites
make check is what every commit has to pass. It is deliberately the
same three things CI would run, so a green local run means a green build.
Testing on Linux¶
The daemon is meant to run on Linux and is developed on macOS, so both suites also run in a container:
make docker-test # gofmt, vet, unit tests and both e2e suites, on linux
make systemd-test # the unit file against a real systemd, in a container
make systemd-test boots systemd as pid 1 in a privileged container and
drives the service: enabled, started, running as an unprivileged user,
logs reaching the journal, a torrent surviving systemctl restart,
reload reopening the log, a SIGKILLed daemon coming back, and a stopped
one staying stopped.
Both have caught real bugs that macOS hid -- a fixed listen port that made parallel test packages collide, and a signal handler installed too late.
Checking VPN detection against a real device¶
The unit tests for internal/vpn classify synthetic interfaces and a fake
sysfs tree, which tests the rules but not the kernel's description of a
real tunnel. To exercise the whole path -- route lookup, source address,
sysfs -- make a tunnel and route the probe destination through it:
docker run --rm -it --cap-add=NET_ADMIN --device /dev/net/tun \
-v "$PWD:/src" -w /src golang:1.25 bash
apt-get update && apt-get install -y iproute2
ip tuntap add mode tun dev tun0 # or: ip link add wg0 type wireguard
ip addr add 10.99.0.2/24 dev tun0
ip link set tun0 up
ip route add 192.0.2.0/24 dev tun0 # the address Detect probes
Then call vpn.Detect(nil) from a throwaway test in the package. Without
the route it reports the container's eth0 and refuses; with it, tun0
(via tun_flags) or wg0 (via DEVTYPE=wireguard) and allows.
On macOS there is nothing to fake -- connect a VPN and run the same
throwaway test, which should name the utun device carrying the traffic.
A Tailscale with no exit node should not satisfy it, since the default
route stays on en0.
What CI runs¶
.github/workflows/ci.yml, on every push and pull request. Each job calls
a make target rather than its own go invocation, so a green run there and
a green make check here mean the same thing:
| job | what |
|---|---|
check |
make check and make e2e, on Linux and macOS |
race |
make test-race |
vuln |
govulncheck ./... -- only vulnerabilities the code reaches |
build |
goreleaser build --snapshot, the release build on every push |
docker |
builds the image and runs torrnado version inside it |
coverage |
a profile, with the totals in the run summary |
.github/workflows/integration.yml runs make systemd-test nightly and on
demand -- it needs a privileged container and takes minutes, which is too
slow for every push and too valuable to run only when someone remembers.
Cutting a release¶
The tag is the version; nothing is committed anywhere to forget to bump.
make check && make e2e
make changelog TAG=v0.1.0 # files the pending commits under that version
git commit -am "chore: changelog for v0.1.0"
git tag -a v0.1.0 -m "v0.1.0"
git push --follow-tags
The tag triggers .github/workflows/release.yml: it reruns make check
against that exact commit -- a tag can be pushed at a commit CI never saw --
then builds four archives with GoReleaser and creates the release, with
notes generated from the same cliff.toml that wrote CHANGELOG.md.
To rehearse any of it locally:
goreleaser check # validate .goreleaser.yaml
goreleaser build --snapshot --clean # build all four targets into dist/
git cliff --latest --strip header # exactly what the release page gets
make changelog uses git-cliff from PATH and falls back to its
container image, so it works on a machine that has never installed it.
The end-to-end suites¶
e2e/ drives the real binary the way a user would rather than calling Go
functions, which catches what unit tests structurally cannot: a subcommand
not wired into the root command, a daemon that fails to detach, a socket
path that is wrong.
Each suite gives itself its own HOME and XDG_DATA_HOME, and finds its
daemon by asking which process holds its socket -- never by pattern
matching on the process name, which would kill the daemon you are
actually using.
Docs¶
This site is MkDocs Material. To work on it:
make docs-serve # http://127.0.0.1:8000, live reload
make docs-build # a strict build, the same one CI runs
Requirements are pinned in docs-requirements.txt so a local build and
the CI build produce the same site.