A maintainable, extensible BitTorrent client (C11, Linux/io_uring) targeting 10 GbE saturation. All torrent functionality is built from scratch; liburing is the only linked third-party dependency on the data path. Implements Phases 1-7 of the roadmap: - core: page-aligned buffer pool, MPMC/Treiber queues, bitfields, worker pool - crypto: SHA-1/256 (SHA-NI + scalar), Merkle (BEP-52), RC4 (MSE) - bencode/metainfo: zero-copy parser, v1/v2/hybrid .torrent + magnet - peer: sans-IO wire codec, MSE/PE handshake state machine, BEP-10, ut_metadata, PEX - piece/storage: block-level multi-peer engine, rarest-first + endgame, per-file completion events + single-file relocate (move-as-you-finish) - tracker/dht: HTTP + UDP (BEP-15) trackers, BEP-5 KRPC iterative lookup - platform: io_uring reactor (SQPOLL, registered buffers, SEND_ZC) - surface: versioned RPC, native plugin ABI, sandboxed Lua scripting, nautd/nautctl Verified against libtorrent (single/multi/hybrid, MSE, magnet-via-DHT, swarm); unit + interop tests green; ASan/UBSan/TSan clean. Scripting reference in docs/scripting.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
Naut-Torrent Lua scripting reference
Naut-Torrent embeds a sandboxed Lua interpreter so you can react to engine events (a torrent finished, a file completed, a peer connected) and drive a small set of actions — most importantly moving a file out of the download directory the moment it finishes, without waiting for the whole torrent.
This document describes the entire script-visible surface: how scripts are
loaded and run, the sandbox, every event hook, the event object passed to
them, and the naut API table.
Implemented in
src/script/script.c; the host API isinclude/naut/script.h. The daemon wiring is inapps/nautd/main.c.
1. Loading a script
Pass a script to the daemon with --script:
nautd --socket /tmp/nautd.sock --script ./my-rules.lua
- One script per daemon. If
--scriptis given more than once, the last path wins. - The file is loaded and executed once at startup, on the main thread, before the event worker starts. Use this top-level run to define your hook functions (and any state they need). If the file fails to load or its top-level code raises, the daemon refuses to start and prints the Lua error.
- After startup the script is event-driven: the functions you defined are called as matching events occur.
-- my-rules.lua — top level runs once at load
local moved = 0
function on_file_complete(event) -- called later, per event
naut.move_file(event.torrent_id, event.index, "/archive/" .. event.path)
moved = moved + 1
end
2. Execution & threading model
- Hooks run on a single dedicated script thread, one event at a time, in the order events were emitted. Your hooks never run concurrently with each other, so ordinary Lua locals/tables need no locking.
- Events reach the script through a bounded queue (
queue_capacity, 256 innautd). The engine never blocks waiting for your script: if the queue is full because a hook is slow, new events are dropped (and counted — see §7). Keep hooks short; offload nothing back onto the engine threads. - The event is copied before it is handed to your hook. Two copied fields
are length-bounded:
messageis truncated to 255 bytes andpathtoPATH_MAX-1. Longer values are silently shortened. - A hook that raises an error does not crash the daemon or stop the script: the error is recorded and the next event proceeds (see §7).
- Hook return values are ignored.
3. The sandbox
The standard libraries are opened and then the dangerous globals are removed, so a script cannot touch the filesystem, spawn processes, load native modules, or compile raw chunks.
Removed (set to nil):
| Global | Why |
|---|---|
os |
process/clock/os.execute/os.remove |
io |
filesystem handles |
package, require |
native/Lua module loading |
debug |
introspection / sandbox escape |
dofile, loadfile |
run code from a file |
load, loadstring |
compile arbitrary chunks — the default "bt" mode accepts binary bytecode, which can escape the VM, so the loaders are denied as defense-in-depth |
Available: the rest of the base library (print, pcall, error,
assert, type, tostring, tonumber, pairs, ipairs, select,
setmetatable, next, …) plus string, table, math, coroutine, and
utf8. Plus the naut table (§6).
print writes to the daemon's stdout/log — handy for debugging rules.
4. Event hooks
Define any subset of these as global functions. Each is optional; an event
with no matching global is simply ignored. Each hook is called with one
argument, the event object.
| Hook | Fired when | Most relevant fields |
|---|---|---|
on_torrent_added(event) |
a torrent is registered with the session | torrent_id |
on_piece_complete(event) |
a piece verifies and is written | torrent_id, index = piece index |
on_file_complete(event) |
a file's last covering piece verifies — the file is final on disk and safe to move, before the torrent finishes | torrent_id, index = file index, path = file path |
on_torrent_finished(event) |
every piece of the torrent is complete | torrent_id |
on_peer_connected(event) |
a peer connection is established | torrent_id, message = peer address (when provided) |
on_alert(event) |
a general engine alert/notice | message = alert text |
function on_torrent_finished(event)
print("torrent " .. event.torrent_id .. " complete")
end
Which fields carry meaningful data depends on the component that emits the event (engine, a plugin, or the
emitRPC). The table above lists the conventional payload for each type. Always guard optional fields (if event.path then ... end) —messageandpatharenilwhen the emitter didn't set them.
5. The event object
The single argument to every hook is a plain Lua table with these fields:
| Field | Lua type | Always present | Meaning |
|---|---|---|---|
event.type |
string |
yes | the event name, e.g. "file_complete" (see §8) |
event.torrent_id |
integer |
yes | the torrent this event belongs to (0 if not torrent-scoped) |
event.index |
integer |
yes | a type-specific index — piece index for piece_complete, file index for file_complete; 0 otherwise |
event.message |
string |
no | free-form text; nil when unset |
event.path |
string |
no | a filesystem path (e.g. the completed file); nil when unset |
The table is freshly created per call; mutating it has no effect on the engine and the table is not reused between events.
function on_file_complete(event)
assert(event.type == "file_complete")
print(("file #%d of torrent %d done: %s")
:format(event.index, event.torrent_id, event.path or "?"))
end
6. The naut API table
A single global table, naut, exposes the actions a script may take.
naut.move_file(torrent_id, file_index, destination)
Move one completed file of a torrent to destination (the headline
"move files as they finish" feature). Typically called from on_file_complete.
Arguments
| # | Name | Lua type | Notes |
|---|---|---|---|
| 1 | torrent_id |
integer |
must be ≥ 0; identifies a torrent registered with the daemon (see prerequisite below) |
| 2 | file_index |
integer |
must be in [0, 2³²-1]; index of the file within the torrent |
| 3 | destination |
string |
target path to move the file to |
Return value: none on success.
Asynchronous semantics — important. move_file does not perform the
move inline on the script thread. It enqueues a bounded command that the daemon
owner thread executes shortly after (this preserves the engine's
shared-nothing threading: the script thread never touches storage directly).
So a successful call means "the move was accepted", not "the file has moved."
The actual relocate (a rename, or copy+unlink across filesystems) runs later;
its success or failure is reported in the daemon log and reflected in the
move/relocate side effects — it is not returned to the script.
Errors (raised as Lua errors — catch with pcall if you don't want them to
abort the hook):
| Message | Cause |
|---|---|
move_file arguments out of range |
torrent_id < 0, file_index < 0, or file_index > 2³²-1 |
move_file is unavailable |
the host did not wire a move handler |
move_file failed: <code> |
the host rejected the command; the integer is a naut_err — most commonly -8 (queue full / backpressure: the owner thread is behind on draining moves) |
function on_file_complete(event)
local ok, err = pcall(naut.move_file,
event.torrent_id, event.index,
"/archive/" .. event.path)
if not ok then
print("could not queue move: " .. tostring(err))
end
end
Prerequisite — register the torrent's storage first. move_file resolves
torrent_id through the daemon's torrent registry (naut_session). Register a
torrent's storage with the add_torrent RPC before any move command can take
effect:
nautctl --socket /tmp/nautd.sock add_torrent \
'{"torrent_id":42,"torrent":"file.torrent","root":"/downloads/42"}'
If torrent_id is unknown when the move drains, the relocate fails with
"not found" in the daemon log (the Lua call itself still succeeded, because it
only queued the command).
7. Error handling & observability
- A hook that raises is caught; the error text is stored as the script's last error and an error counter is incremented. The script keeps running.
- A global named after a hook that is not a function (e.g. you set
on_alert = 5) is treated as an error for that event. - Counters are exposed over RPC via
nautctl statusunder thescriptobject:Counter Meaning queuedevents accepted into the script queue handledhook invocations that returned without error droppedevents discarded because the queue was full errorshooks that raised, weren't functions, or had an unknown type move_requestssuccessful naut.move_filecalls (commands queued)
nautctl --socket /tmp/nautd.sock status
# { ... "script": { "queued": 12, "handled": 12, "dropped": 0,
# "errors": 0, "move_requests": 3 }, ... }
8. Event type name strings
event.type (and the emit RPC type field) uses these exact strings:
torrent_added, piece_complete, file_complete, torrent_finished,
peer_connected, alert.
You can inject any of them for testing with the emit RPC:
nautctl --socket /tmp/nautd.sock emit \
'{"type":"file_complete","torrent_id":42,"index":0,"path":"/downloads/42/movie.mkv"}'
9. Complete example
-- archive-on-finish.lua
-- Move each file to /archive as it completes, and log torrent completion.
local archive = "/archive"
function on_file_complete(event)
if not event.path then return end
local name = event.path:match("[^/]+$") or event.path
local ok, err = pcall(naut.move_file,
event.torrent_id, event.index,
archive .. "/" .. name)
if ok then
print(("archived file #%d of torrent %d -> %s/%s")
:format(event.index, event.torrent_id, archive, name))
else
print("move failed to queue: " .. tostring(err))
end
end
function on_torrent_finished(event)
print("torrent " .. event.torrent_id .. " fully downloaded")
end
Run it:
nautd --socket /tmp/nautd.sock --script ./archive-on-finish.lua &
nautctl --socket /tmp/nautd.sock add_torrent \
'{"torrent_id":1,"torrent":"big.torrent","root":"/downloads/1"}'