Skip to content

The daemon

There is one long-running process, the daemon, and everything else is a thin client that connects to it over a Unix domain socket ($XDG_DATA_HOME/torrnado/daemon.sock by default, usually ~/.local/share/torrnado/daemon.sock).

  • torrnado (no arguments) attaches the TUI to the daemon. If nothing is listening on the socket, it spawns torrnado daemon as a detached background process first (logging to $XDG_DATA_HOME/torrnado/daemon.log), waits for it to come up, then attaches. Quitting the TUI (q) does not stop the daemon - your torrents keep running.
  • torrnado daemon runs the engine in the foreground until interrupted (Ctrl-C) or sent SIGTERM. This is what gets spawned in the background automatically, but you can also run it yourself directly if you'd rather manage it with systemd/launchd/tmux than let torrnado manage it.
  • torrnado status reports whether a daemon owns the socket, and torrnado stop shuts it down and waits. Both identify it by the lock it holds rather than by process name; status is the one subcommand that will never start a daemon.
  • torrnado add/remove/pause/resume/recheck/priority/limit/move/list are scriptable passthroughs to a running daemon (spawning one if needed, same as the bare command). Everything you can do in the TUI's command palette, you can also do from a shell script.

To see whether one is running, and to stop it:

torrnado status     # is a daemon running, which process, and what is it doing
torrnado stop       # ask it to stop, and wait until it has shut down cleanly

torrnado status finds the daemon by the exclusive lock it holds on <state_dir>/daemon.sock.lock for as long as it runs - the same lock that stops a second daemon claiming the socket. Whoever holds it is the daemon for this state directory, whatever the binary happens to be called, and the kernel releases it however the process dies. That is why it does not match on a process name: pkill -f "torrnado daemon" quietly finds nothing when the running copy was installed under another name, and can just as easily match a daemon belonging to a different state directory.

torrnado stop sends its request down the socket the daemon is already listening on, so it reaches the daemon for this state directory and nothing else - it needs no pid, and it can never signal a process that merely looks like the daemon. It waits rather than firing and forgetting, because shutting down is when the session is written: returning earlier would invite starting a new daemon while the old one is still saving. What it waits on is that same lock being released, which is exactly the moment a restart would succeed. --timeout 0 returns immediately if you would rather not wait.

A daemon still stops on SIGTERM exactly as it always did - that is how a service manager stops it, and none of this changes that.

Stopping is still not something to do by reflex. A daemon that is seeding is doing its job, and leaving it running is the normal state for a machine you have set up to do this.

torrnado status never starts a daemon, which no other subcommand can claim: the rest dial the socket and spawn one when nothing answers, so asking them whether a daemon is running would make the answer yes. It also distinguishes a daemon that is running and answering from one that is running but too busy to accept a connection - a real state, since a daemon part-way through a hash check can miss any timeout - because mistaking the second for "not running" is how someone ends up with two engines on one data directory.

Getting torrents in without logging in

watch_dir points the daemon at a directory and it adds any .torrent file that appears there. That is the last manual step in an otherwise unattended box: without it the only ways in are torrnado add over SSH and the TUI, and with it anything that can write a file can add a torrent - rsync from your laptop, a synced download folder, a file manager on a Samba share, a cron job.

watch_dir = "/srv/torrent/inbox"

A file is only taken once its size has stopped changing, so a .torrent still being copied over a share is not read halfway through. Once added it is renamed to <name>.torrent.added, and one that could not be parsed to .failed - renamed rather than deleted, because the file dropped in may be the only copy, and the marker is also what stops it being added again after a restart. Sweep them whenever you like.

The rule for what counts is the same one torrnado add <directory> uses: .torrent files directly inside, no recursive walk.

Running it unattended

The daemon is the whole program; the TUI and the CLI are just clients. So a headless box that downloads things is torrnado daemon running under whatever supervises processes there (systemd, launchd, tmux - it makes no difference to torrnado), driven over SSH.

It comes back after a restart. Every change is written to <state_dir>/session.json - the torrent list, paused state, save paths, per-torrent rate limits, per-file priorities, when each was added - next to a copy of each torrent's metainfo in <state_dir>/torrents/. On start the daemon re-adds them all, and logs how many came back. Data already on disk is not re-downloaded or re-verified: the piece-completion database was always persistent, and this is what tells the daemon which torrents to look it up for.

A session file that cannot be read is logged and skipped, one bad record at a time. A server that refuses to boot over a malformed record is worse than one that comes back with fewer torrents, so it starts either way - journalctl is where you find out which happened.

It can require a VPN. vpn.required = true holds every transfer while the machine is not on one, and lets them go again by itself when it comes back - which is the case a box left running for weeks actually hits, when the VPN client reconnects at 4am and nobody is watching. It is off by default. Read what it does and does not cover in Configuration before relying on it.

Logs go to stderr as text with timestamps and levels, which is what a service manager wants: journald timestamps and stores it with no further configuration.

time=2026-08-05T13:26:28.488-04:00 level=INFO msg="daemon starting" pid=83154 ...
time=2026-08-05T13:26:28.491-04:00 level=INFO msg="session restored" torrents=2
time=2026-08-05T13:26:28.491-04:00 level=INFO msg="daemon ready" socket=... stream=127.0.0.1:64853

Set log.file to write to a file instead. SIGHUP reopens it, which is what logrotate needs after renaming the old one away - without that the daemon goes on writing to a file that no longer has a name, and the disk never gets the space back.

The torrent library's own output is captured into the same stream at log.library_level, tagged src=torrent. It is much noisier than torrnado is - it reports every tracker that misbehaves - which is why it has its own level rather than sharing log.level. Panics still go straight to stderr through the Go runtime, whatever log.file says, so leave stderr attached to something.

Two things to know before deploying:

  • CGO_ENABLED=0 changes the piece-completion database. With cgo it is SQLite (.torrent.db in the download directory); without it, bbolt (.torrent.bolt.db). Neither reads the other's file, so a data directory populated by one build re-verifies from scratch under the other. Pin the choice deliberately when you build for a server, and keep it pinned.
  • Previews are loopback-only. The stream server binds 127.0.0.1 on an OS-assigned port with a per-session token, so torrnado preview URLs from a remote daemon are only reachable through an SSH tunnel (ssh -L 8080:127.0.0.1:<port> server). That is the intended design, not an oversight: there is no authentication anywhere in torrnado beyond the filesystem permissions on the socket, so nothing it serves should be reachable from a network.

Running a second daemon alongside the first means giving it its own daemon_socket and its own state_dir. Only the socket is guarded against sharing (by a lock file); two daemons pointed at one state directory will each restore the other's torrents and overwrite the other's session file.

There is no remote control protocol. The socket is local by construction, and SSH already solves the problem properly.

Deployment recipes have their own pages: systemd and Docker.