From 9f57ada92cdc2569d67b7f32a4cae1b701654dfa Mon Sep 17 00:00:00 2001 From: melqtx Date: Wed, 5 Aug 2026 11:40:37 +0530 Subject: [PATCH] feat: expose supported torrent tuning knobs --- README.md | 23 +++++++++++ go.mod | 2 +- internal/config/config.go | 17 ++++++++ internal/config/config_test.go | 56 +++++++++++++++++++++++++ internal/engine/engine.go | 71 ++++++++++++++++++++++++++++++++ internal/engine/tuning_test.go | 74 ++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 internal/engine/tuning_test.go diff --git a/README.md b/README.md index c06da26..7712439 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/go.mod b/go.mod index ef2d16f..d6c85ba 100644 --- a/go.mod +++ b/go.mod @@ -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 ) @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 70d30b9..cff5cbb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e600101..71cf0c2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 779dd1c..0d98ae6 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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" @@ -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()) @@ -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 diff --git a/internal/engine/tuning_test.go b/internal/engine/tuning_test.go new file mode 100644 index 0000000..4829c07 --- /dev/null +++ b/internal/engine/tuning_test.go @@ -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) + } + } +}