Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ metadata_cache:
max_entries: 512
```

Advanced torrent-client limits are optional; zero or omitted values retain the
anacrolix defaults:

```yaml
torrent_tuning:
half_open_conns_per_torrent: 25
total_half_open_conns: 100
piece_hashers_per_torrent: 2
max_unverified_bytes: 67108864
dial_rate_limit: 10
peer_high_water: 500
peer_low_water: 50
download_rate_limit: 0
upload_rate_limit: 0
disable_aggressive_upload: false
no_upload: false
```

Transfer rates are client-wide bytes per second; dial rate is dials per second.
`no_upload` prevents all torrent uploads, including seeding. The torrent library
does not expose its webseed concurrency as a runtime client setting, so tork
does not claim to configure it here.

## SOCKS5 proxy

For the usual local Tor setup, one command is enough:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/sahilm/fuzzy v0.1.3
golang.org/x/net v0.52.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -109,7 +110,6 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
Expand Down
17 changes: 17 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ type MetadataCacheConfig struct {
MaxEntries int `yaml:"max_entries"`
}

// TorrentTuningConfig overrides anacrolix client defaults. Numeric zero means
// "leave the library default unchanged". Byte rates are client-wide bytes/s.
type TorrentTuningConfig struct {
HalfOpenConnsPerTorrent int `yaml:"half_open_conns_per_torrent,omitempty"`
TotalHalfOpenConns int `yaml:"total_half_open_conns,omitempty"`
PieceHashersPerTorrent int `yaml:"piece_hashers_per_torrent,omitempty"`
MaxUnverifiedBytes int64 `yaml:"max_unverified_bytes,omitempty"`
DialRateLimit int `yaml:"dial_rate_limit,omitempty"`
PeerHighWater int `yaml:"peer_high_water,omitempty"`
PeerLowWater int `yaml:"peer_low_water,omitempty"`
DownloadRateLimit int64 `yaml:"download_rate_limit,omitempty"`
UploadRateLimit int64 `yaml:"upload_rate_limit,omitempty"`
DisableAggressiveUpload bool `yaml:"disable_aggressive_upload,omitempty"`
NoUpload bool `yaml:"no_upload,omitempty"`
}

// Interval is the gap between automatic health checks. A missing or nonsensical
// interval_hours falls back to the daily default rather than turning every
// launch into a provider probe.
Expand All @@ -84,6 +100,7 @@ type Config struct {
Health HealthConfig `yaml:"health"`
Proxy ProxyConfig `yaml:"proxy"`
MetadataCache MetadataCacheConfig `yaml:"metadata_cache"`
TorrentTuning TorrentTuningConfig `yaml:"torrent_tuning,omitempty"`
Providers map[string]ProviderConfig `yaml:"providers"`

dir string // ~/.tork, resolved at load time
Expand Down
56 changes: 56 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,62 @@ func TestLoadFromReadsMetadataCacheSettings(t *testing.T) {
}
}

func TestLoadFromReadsTorrentTuning(t *testing.T) {
dir := filepath.Join(t.TempDir(), ".tork")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
yaml := `torrent_tuning:
half_open_conns_per_torrent: 30
total_half_open_conns: 120
piece_hashers_per_torrent: 4
max_unverified_bytes: 134217728
dial_rate_limit: 15
peer_high_water: 600
peer_low_water: 60
download_rate_limit: 5242880
upload_rate_limit: 1048576
disable_aggressive_upload: true
no_upload: true
`
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadFrom(dir)
if err != nil {
t.Fatal(err)
}
tuning := cfg.TorrentTuning
if tuning.HalfOpenConnsPerTorrent != 30 || tuning.TotalHalfOpenConns != 120 ||
tuning.PieceHashersPerTorrent != 4 || tuning.MaxUnverifiedBytes != 134217728 ||
tuning.DialRateLimit != 15 || tuning.PeerHighWater != 600 || tuning.PeerLowWater != 60 ||
tuning.DownloadRateLimit != 5242880 || tuning.UploadRateLimit != 1048576 ||
!tuning.DisableAggressiveUpload || !tuning.NoUpload {
t.Fatalf("torrent tuning = %+v", tuning)
}
}

func TestTorrentTuningDefaultsAndPartialConfigStayZero(t *testing.T) {
cfg := Default(t.TempDir())
if cfg.TorrentTuning != (TorrentTuningConfig{}) {
t.Fatalf("default tuning changes library behavior: %+v", cfg.TorrentTuning)
}
dir := filepath.Join(t.TempDir(), ".tork")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("torrent_tuning:\n dial_rate_limit: 8\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadFrom(dir)
if err != nil {
t.Fatal(err)
}
if cfg.TorrentTuning.DialRateLimit != 8 || cfg.TorrentTuning.HalfOpenConnsPerTorrent != 0 || cfg.TorrentTuning.NoUpload {
t.Fatalf("partial tuning populated omitted values: %+v", cfg.TorrentTuning)
}
}

func TestOverrideDownloadDir(t *testing.T) {
cfg := Default(t.TempDir())
target := filepath.Join(t.TempDir(), "custom", "dl")
Expand Down
71 changes: 71 additions & 0 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"golang.org/x/time/rate"

"github.com/melqtx/tork/internal/config"
"github.com/melqtx/tork/internal/intake"
Expand Down Expand Up @@ -220,6 +221,72 @@ type Engine struct {
metainfo *metacache.Cache
}

func applyTorrentTuning(cc *torrent.ClientConfig, tuning config.TorrentTuningConfig) error {
intValues := []struct {
name string
value int
}{
{"half_open_conns_per_torrent", tuning.HalfOpenConnsPerTorrent},
{"total_half_open_conns", tuning.TotalHalfOpenConns},
{"piece_hashers_per_torrent", tuning.PieceHashersPerTorrent},
{"dial_rate_limit", tuning.DialRateLimit},
{"peer_high_water", tuning.PeerHighWater},
{"peer_low_water", tuning.PeerLowWater},
}
for _, field := range intValues {
if field.value < 0 {
return fmt.Errorf("torrent_tuning.%s cannot be negative", field.name)
}
}
int64Values := []struct {
name string
value int64
}{
{"max_unverified_bytes", tuning.MaxUnverifiedBytes},
{"download_rate_limit", tuning.DownloadRateLimit},
{"upload_rate_limit", tuning.UploadRateLimit},
}
for _, field := range int64Values {
if field.value < 0 {
return fmt.Errorf("torrent_tuning.%s cannot be negative", field.name)
}
}
if tuning.HalfOpenConnsPerTorrent > 0 {
cc.HalfOpenConnsPerTorrent = tuning.HalfOpenConnsPerTorrent
}
if tuning.TotalHalfOpenConns > 0 {
cc.TotalHalfOpenConns = tuning.TotalHalfOpenConns
}
if tuning.PieceHashersPerTorrent > 0 {
cc.PieceHashersPerTorrent = tuning.PieceHashersPerTorrent
}
if tuning.MaxUnverifiedBytes > 0 {
cc.MaxUnverifiedBytes = tuning.MaxUnverifiedBytes
}
if tuning.DialRateLimit > 0 {
cc.DialRateLimiter = rate.NewLimiter(rate.Limit(tuning.DialRateLimit), tuning.DialRateLimit)
}
if tuning.PeerHighWater > 0 {
cc.TorrentPeersHighWater = tuning.PeerHighWater
}
if tuning.PeerLowWater > 0 {
cc.TorrentPeersLowWater = tuning.PeerLowWater
}
if cc.TorrentPeersLowWater > cc.TorrentPeersHighWater {
return fmt.Errorf("torrent_tuning peer_low_water (%d) exceeds peer_high_water (%d)", cc.TorrentPeersLowWater, cc.TorrentPeersHighWater)
}
const transferBurst = 1 << 20
if tuning.DownloadRateLimit > 0 {
cc.DownloadRateLimiter = rate.NewLimiter(rate.Limit(tuning.DownloadRateLimit), transferBurst)
}
if tuning.UploadRateLimit > 0 {
cc.UploadRateLimiter = rate.NewLimiter(rate.Limit(tuning.UploadRateLimit), transferBurst)
}
cc.DisableAggressiveUpload = tuning.DisableAggressiveUpload
cc.NoUpload = tuning.NoUpload
return nil
}

func New(cfg *config.Config) (*Engine, error) {
dbPath := filepath.Join(cfg.PieceCompletionDir(), ".torrent.bolt.db")
pc, err := storage.NewBoltPieceCompletion(cfg.PieceCompletionDir())
Expand Down Expand Up @@ -248,6 +315,10 @@ func New(cfg *config.Config) (*Engine, error) {
if cfg.MaxConnections > 0 {
cc.EstablishedConnsPerTorrent = cfg.MaxConnections
}
if err := applyTorrentTuning(cc, cfg.TorrentTuning); err != nil {
pc.Close()
return nil, err
}
runtime := cfg.ProxyRuntime()
strictProxy := runtime != nil && runtime.Enabled()
torrentHTTP := torrentClient
Expand Down
74 changes: 74 additions & 0 deletions internal/engine/tuning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package engine

import (
"strings"
"testing"

"github.com/anacrolix/torrent"

"github.com/melqtx/tork/internal/config"
)

func TestApplyTorrentTuningZeroPreservesDefaults(t *testing.T) {
cc := torrent.NewDefaultClientConfig()
wantHalf, wantTotal := cc.HalfOpenConnsPerTorrent, cc.TotalHalfOpenConns
wantHashers, wantBytes := cc.PieceHashersPerTorrent, cc.MaxUnverifiedBytes
wantHigh, wantLow := cc.TorrentPeersHighWater, cc.TorrentPeersLowWater
wantDial, wantDown, wantUp := cc.DialRateLimiter, cc.DownloadRateLimiter, cc.UploadRateLimiter
if err := applyTorrentTuning(cc, config.TorrentTuningConfig{}); err != nil {
t.Fatal(err)
}
if cc.HalfOpenConnsPerTorrent != wantHalf || cc.TotalHalfOpenConns != wantTotal ||
cc.PieceHashersPerTorrent != wantHashers || cc.MaxUnverifiedBytes != wantBytes ||
cc.TorrentPeersHighWater != wantHigh || cc.TorrentPeersLowWater != wantLow ||
cc.DialRateLimiter != wantDial || cc.DownloadRateLimiter != wantDown || cc.UploadRateLimiter != wantUp {
t.Fatal("zero tuning changed library defaults")
}
}

func TestApplyTorrentTuningMapsSupportedFields(t *testing.T) {
cc := torrent.NewDefaultClientConfig()
tuning := config.TorrentTuningConfig{
HalfOpenConnsPerTorrent: 31, TotalHalfOpenConns: 120,
PieceHashersPerTorrent: 4, MaxUnverifiedBytes: 128 << 20,
DialRateLimit: 17, PeerHighWater: 700, PeerLowWater: 70,
DownloadRateLimit: 5 << 20, UploadRateLimit: 2 << 20,
DisableAggressiveUpload: true, NoUpload: true,
}
if err := applyTorrentTuning(cc, tuning); err != nil {
t.Fatal(err)
}
if cc.HalfOpenConnsPerTorrent != 31 || cc.TotalHalfOpenConns != 120 ||
cc.PieceHashersPerTorrent != 4 || cc.MaxUnverifiedBytes != 128<<20 ||
cc.TorrentPeersHighWater != 700 || cc.TorrentPeersLowWater != 70 {
t.Fatalf("mapped config = %+v", cc)
}
if cc.DialRateLimiter.Limit() != 17 || cc.DialRateLimiter.Burst() != 17 {
t.Fatalf("dial limiter = %v/%d", cc.DialRateLimiter.Limit(), cc.DialRateLimiter.Burst())
}
if cc.DownloadRateLimiter.Limit() != 5<<20 || cc.DownloadRateLimiter.Burst() != 1<<20 ||
cc.UploadRateLimiter.Limit() != 2<<20 || cc.UploadRateLimiter.Burst() != 1<<20 {
t.Fatal("transfer limiters were not configured safely")
}
if !cc.DisableAggressiveUpload || !cc.NoUpload {
t.Fatal("upload flags were not mapped")
}
}

func TestApplyTorrentTuningRejectsInvalidValues(t *testing.T) {
cases := []struct {
tuning config.TorrentTuningConfig
field string
}{
{config.TorrentTuningConfig{DialRateLimit: -1}, "dial_rate_limit"},
{config.TorrentTuningConfig{DownloadRateLimit: -1}, "download_rate_limit"},
{config.TorrentTuningConfig{PeerHighWater: 20}, "peer_low_water"}, // effective default low-water is 50
{config.TorrentTuningConfig{PeerHighWater: 100, PeerLowWater: 101}, "peer_low_water"},
}
for _, tc := range cases {
err := applyTorrentTuning(torrent.NewDefaultClientConfig(), tc.tuning)
if err == nil || !strings.Contains(err.Error(), tc.field) {
t.Fatalf("invalid tuning %+v error = %v, want field %q", tc.tuning, err, tc.field)
}
}
}
Loading