all: cut bloated HTTP-client and tuning options

This is a personal torrent client, not a web client or daemon, so the
rarely-used HTTP-client knobs and redundant tuning flags are gone — flag,
config field, implementation, and tests removed for each. The option
surface drops from 54 to 28.

Web-client cluster removed: --all-proxy (HTTP_PROXY env still works),
load/save-cookies, --referer, -U/--user-agent, --http-user/--http-passwd,
--conditional-get, --remote-time, --check-certificate, --ca-certificate,
--lowest-speed-limit. Pass --header for a one-off auth/cookie/UA header.

Redundant knobs removed: -x and -k (folded into -s), --connect-timeout,
--retry-wait, per-item --max-download-limit/-u, --bt-max-peers,
--bt-stop-timeout, --disable-ipv6, --auto-save-interval, --human-readable,
--stop, -T (a positional .torrent works). follow-torrent and human-readable
are now unconditional defaults.
This commit is contained in:
2026-06-22 08:30:12 +09:00
parent 2afcb861e4
commit cf9082ff72
15 changed files with 157 additions and 1053 deletions

View File

@@ -1,22 +0,0 @@
package httpdl
import (
"os"
"path/filepath"
"testing"
)
func TestLoadCAPool(t *testing.T) {
// A missing file is an error, not a silent fall-back to the system roots.
if _, err := loadCAPool(filepath.Join(t.TempDir(), "absent.pem")); err == nil {
t.Errorf("loadCAPool(missing): want error")
}
// A file with no PEM certificates is an error (AppendCertsFromPEM found none).
bad := filepath.Join(t.TempDir(), "bad.pem")
if err := os.WriteFile(bad, []byte("not a certificate\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := loadCAPool(bad); err == nil {
t.Errorf("loadCAPool(garbage): want error (no certificates)")
}
}

View File

@@ -1,97 +0,0 @@
package httpdl
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/hanbok/got/download"
)
// TestConditionalGet304 checks that --conditional-get sends If-Modified-Since
// for an existing local file and treats a 304 as success without rewriting the
// file.
func TestConditionalGet304(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "file.bin")
const existing = "already here"
if err := os.WriteFile(out, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
gotIfModSince := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
gotIfModSince = true
}
w.WriteHeader(http.StatusNotModified)
}))
defer srv.Close()
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
ConditionalGet: true,
AllowOverwrite: true,
UserAgent: "got-test",
})
if err := d.Run(context.Background()); err != nil {
t.Fatalf("conditional-get 304 should succeed, got %v", err)
}
if !gotIfModSince {
t.Fatal("expected an If-Modified-Since header on the request")
}
if d.Stat().Status != download.Complete {
t.Fatalf("status = %v, want Complete", d.Stat().Status)
}
// The file must be untouched: 304 means "up to date".
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(b) != existing {
t.Fatalf("file was rewritten: %q, want %q", b, existing)
}
}
// TestConditionalGetSkippedWithControlFile verifies the rule that
// --conditional-get is ignored when a .got control file exists, so an
// in-progress resume is never short-circuited by a 304.
func TestConditionalGetSkippedWithControlFile(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "file.bin")
if err := os.WriteFile(out, []byte("partial"), 0o644); err != nil {
t.Fatal(err)
}
// A control file present means a resume is in flight.
if err := os.WriteFile(controlPath(out), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
sawIfModSince := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
sawIfModSince = true
}
// Serve a small body so the run can complete.
http.Error(w, "no range", http.StatusOK)
}))
defer srv.Close()
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
ConditionalGet: true,
AllowOverwrite: true,
UserAgent: "got-test",
})
_ = d.Run(context.Background())
if sawIfModSince {
t.Fatal("If-Modified-Since must not be sent when a control file exists")
}
}

View File

@@ -1,177 +0,0 @@
package httpdl
import (
"bufio"
"fmt"
"math"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Cookie handling implements the --load-cookies / --save-cookies options, which
// read and write the Netscape/Mozilla cookies.txt format (one tab-separated
// cookie per line). We back it with the standard net/http/cookiejar so the
// http.Client applies and tracks Set-Cookie headers automatically; the file
// format is just the on-disk representation of that jar.
// loadCookieJar builds a cookie jar seeded from a Netscape cookies.txt file. A
// missing file yields an empty jar (not an error): there are simply no cookies
// to load. A malformed line is skipped, giving per-line tolerance.
func loadCookieJar(path string) (*cookiejar.Jar, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
if path == "" {
return jar, nil
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return jar, nil // nothing to load yet
}
return nil, fmt.Errorf("load-cookies %s: %w", path, err)
}
defer f.Close()
// Group cookies by the URL their domain implies, since SetCookies keys on a
// URL. We synthesise a representative URL per (domain, secure, path).
byURL := map[string][]*http.Cookie{}
urls := map[string]*url.URL{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
c, u := parseNetscapeCookie(line)
if c == nil {
continue
}
key := u.String()
if _, ok := urls[key]; !ok {
urls[key] = u
}
byURL[key] = append(byURL[key], c)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("load-cookies %s: %w", path, err)
}
for key, cs := range byURL {
jar.SetCookies(urls[key], cs)
}
return jar, nil
}
// parseNetscapeCookie parses one cookies.txt line into a cookie plus the URL it
// belongs to, or (nil, nil) for a comment, blank or malformed line. The format
// is seven tab-separated fields:
//
// domain includeSubdomains path secure expiry name value
//
// Curl marks HttpOnly cookies with a "#HttpOnly_" line prefix rather than a
// real comment, so we recognise that prefix instead of dropping the line.
// Honouring HttpOnly_ rather than skipping every "#" line never loses a cookie.
func parseNetscapeCookie(line string) (*http.Cookie, *url.URL) {
httpOnly := false
if strings.HasPrefix(line, "#HttpOnly_") {
httpOnly = true
line = strings.TrimPrefix(line, "#HttpOnly_")
} else if strings.HasPrefix(line, "#") {
return nil, nil
}
if strings.TrimSpace(line) == "" {
return nil, nil
}
f := strings.Split(line, "\t")
if len(f) < 6 {
return nil, nil
}
domain := f[0]
hostOnly := !strings.EqualFold(f[1], "TRUE")
path := f[2]
secure := strings.EqualFold(f[3], "TRUE")
name := f[5]
value := ""
if len(f) >= 7 {
value = f[6]
}
if name == "" || domain == "" {
return nil, nil
}
c := &http.Cookie{
Name: name,
Value: value,
Path: path,
Secure: secure,
HttpOnly: httpOnly,
}
// A non-host-only cookie (includeSubdomains TRUE) carries a Domain attribute
// so the jar applies it to subdomains; a host-only one leaves Domain empty so
// the jar binds it to the exact request host.
if !hostOnly {
c.Domain = domain
}
// expiry 0 means a session cookie; otherwise it is a Unix timestamp. The jar
// drops already-expired cookies on load.
if exp, err := strconv.ParseInt(strings.TrimSpace(f[4]), 10, 64); err == nil && exp > 0 {
c.Expires = time.Unix(exp, 0)
}
// The jar needs a URL whose host and scheme match the cookie. A leading dot
// denotes a domain cookie; strip it for the host but keep the cookie Domain
// so the jar treats it as domain-wide.
host := strings.TrimPrefix(domain, ".")
scheme := "http"
if secure {
scheme = "https"
}
u := &url.URL{Scheme: scheme, Host: host, Path: path}
return c, u
}
// saveCookieJar writes every cookie the jar holds (for the hosts we have
// contacted) back to path in Netscape format, implementing --save-cookies on
// exit. Because net/http/cookiejar exposes cookies only per URL, we ask it for
// the cookies of each host we recorded a request against.
func saveCookieJar(jar *cookiejar.Jar, urls []*url.URL, path string) error {
if path == "" || jar == nil {
return nil
}
var b strings.Builder
b.WriteString("# Netscape HTTP Cookie File\n")
seen := map[string]bool{}
for _, u := range urls {
for _, c := range jar.Cookies(u) {
// Cookies() drops the domain/path/expiry, so reconstruct sensible
// values: host-only domain, root path, far-future expiry. This is the
// best a host-only readback can do; it round-trips the name/value pair
// that authenticated downloads actually need.
key := u.Host + "\x00" + c.Name
if seen[key] {
continue
}
seen[key] = true
expiry := int64(math.MaxInt32) // session-stable far future
line := strings.Join([]string{
u.Host, "FALSE", "/",
boolToTRUE(u.Scheme == "https"),
strconv.FormatInt(expiry, 10),
c.Name, c.Value,
}, "\t")
b.WriteString(line)
b.WriteByte('\n')
}
}
return os.WriteFile(path, []byte(b.String()), 0o600)
}
func boolToTRUE(b bool) string {
if b {
return "TRUE"
}
return "FALSE"
}

View File

@@ -1,126 +0,0 @@
package httpdl
import (
"net/http"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
func TestParseNetscapeCookie(t *testing.T) {
future := time.Now().Add(24 * time.Hour).Unix()
tests := []struct {
name string
line string
wantNil bool
wantName string
wantValue string
wantDom string // expected http.Cookie.Domain (empty for host-only)
wantHTTP bool
}{
{
name: "domain cookie",
line: ".example.com\tTRUE\t/\tFALSE\t" + itoa(future) + "\tSID\tabc123",
wantName: "SID",
wantValue: "abc123",
wantDom: ".example.com",
},
{
name: "host-only cookie",
line: "example.com\tFALSE\t/\tTRUE\t" + itoa(future) + "\ttoken\txyz",
wantName: "token",
wantValue: "xyz",
wantDom: "", // host-only: no Domain attribute
},
{
name: "httponly prefix kept",
line: "#HttpOnly_example.com\tFALSE\t/\tFALSE\t" + itoa(future) + "\tHO\tsecret",
wantName: "HO",
wantValue: "secret",
wantHTTP: true,
},
{
name: "empty value field",
line: "example.com\tFALSE\t/\tFALSE\t0\tflag\t",
wantName: "flag",
wantValue: "",
},
{
name: "comment line",
line: "# this is a comment",
wantNil: true,
},
{
name: "blank line",
line: " ",
wantNil: true,
},
{
name: "too few fields",
line: "example.com\tFALSE\t/\tFALSE\t0",
wantNil: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c, u := parseNetscapeCookie(tc.line)
if tc.wantNil {
if c != nil {
t.Fatalf("expected nil cookie, got %+v", c)
}
return
}
if c == nil {
t.Fatal("expected a cookie, got nil")
}
if c.Name != tc.wantName || c.Value != tc.wantValue {
t.Fatalf("name/value = %q/%q, want %q/%q", c.Name, c.Value, tc.wantName, tc.wantValue)
}
if c.Domain != tc.wantDom {
t.Fatalf("domain = %q, want %q", c.Domain, tc.wantDom)
}
if c.HttpOnly != tc.wantHTTP {
t.Fatalf("httponly = %v, want %v", c.HttpOnly, tc.wantHTTP)
}
if u == nil {
t.Fatal("expected a URL, got nil")
}
})
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
func TestLoadCookieJarMissingFile(t *testing.T) {
// A missing cookies file is not an error: there are simply no cookies to load.
jar, err := loadCookieJar(filepath.Join(t.TempDir(), "nope.txt"))
if err != nil {
t.Fatalf("missing file should not error: %v", err)
}
if jar == nil {
t.Fatal("expected an empty jar, got nil")
}
}
func TestLoadCookieJarAppliesCookie(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cookies.txt")
future := time.Now().Add(24 * time.Hour).Unix()
data := "# Netscape HTTP Cookie File\n" +
".example.com\tTRUE\t/\tFALSE\t" + itoa64(future) + "\tSID\tabc123\n"
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
jar, err := loadCookieJar(path)
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "http://www.example.com/file", nil)
got := jar.Cookies(req.URL)
if len(got) != 1 || got[0].Name != "SID" || got[0].Value != "abc123" {
t.Fatalf("jar did not apply the domain cookie to a subdomain: %+v", got)
}
}

View File

@@ -9,8 +9,6 @@ package httpdl
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"hash"
@@ -18,7 +16,6 @@ import (
"mime"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path"
@@ -35,6 +32,15 @@ import (
const readBuf = 32 * 1024
// minSplitSize is the smallest piece a file is split into: a segment shorter
// than this is not worth its own connection, so makeSegments stops dividing once
// the pieces would fall below it.
const minSplitSize = 20 << 20
// userAgent is sent on every request; there is no flag to change it. A site that
// needs a different one can be given a User-Agent header via --header.
const userAgent = "got/1.0"
// ErrOutputExists reports that the resolved output file already exists and
// neither --allow-overwrite nor -c was given. main maps it to a distinct exit
// code via errors.Is, so it must stay on the error chain (CONTRACT).
@@ -45,60 +51,21 @@ var ErrOutputExists = errors.New("output file already exists")
// (CONTRACT).
var ErrNotFound = errors.New("not found")
// ErrTooSlow reports a transfer aborted by --lowest-speed-limit. main maps it to
// exit code 5 via errors.Is, so it must stay on the error chain (CONTRACT).
var ErrTooSlow = errors.New("download speed too low")
// Config holds everything one HTTP download needs. It is assembled by main from
// the resolved CLI options.
type Config struct {
Dir string
Out string
Split int // --split: total connections across all mirrors
MaxConnPerServer int // --max-connection-per-server: per-host connection cap
MinSplit int64
Tries int // 0 = unlimited
Timeout time.Duration
FileAlloc string // none | prealloc | trunc | falloc
Continue bool
AllowOverwrite bool
AutoRename bool
Headers []string
UserAgent string
Referer string
Proxy string
CheckCert bool
Limit int64 // per-download bytes/s, 0 = unlimited
OverallLimiter *rate.Limiter // shared across all downloads, may be nil
// RetryWait is the pause between retries of a transient failure
// (--retry-wait). 0 means retry immediately with no sleep.
RetryWait time.Duration
// AutoSaveInterval controls how often the .got control file is rewritten
// (--auto-save-interval). <=0 falls back to 60s.
AutoSaveInterval time.Duration
// ConnectTimeout is the time allowed to establish the TCP/TLS connection
// (--connect-timeout, default 60s), distinct from Timeout which
// covers stalls once data is flowing. <=0 falls back to Timeout.
ConnectTimeout time.Duration
// LoadCookies / SaveCookies are paths to a Netscape cookies.txt read into
// the client's jar before the download and written back on exit.
LoadCookies string
SaveCookies string
// ConditionalGet sends If-Modified-Since (from the existing local file's
// mtime) so a 304 reports success without re-downloading
// (--conditional-get).
ConditionalGet bool
// RemoteTime sets the finished file's mtime from the server's Last-Modified
// header (-R / --remote-time).
RemoteTime bool
// HTTPUser / HTTPPasswd enable HTTP Basic auth (--http-user/passwd).
HTTPUser string
HTTPPasswd string
// LowestSpeedLimit aborts a download whose speed stays at or below this many
// bytes/sec for a sustained window (--lowest-speed-limit). 0 = off.
LowestSpeedLimit int64
Dir string
Out string
Split int // --split: total connections across all mirrors
MinSplit int64 // smallest piece to split into; 0 = minSplitSize default (internal, no flag)
Tries int // --max-tries: 0 = unlimited
Timeout time.Duration
FileAlloc string // none | prealloc | trunc | falloc
Continue bool
AllowOverwrite bool
AutoRename bool
Headers []string // --header: extra request headers
OverallLimiter *rate.Limiter // shared across all downloads, may be nil
// Checksum is a "<type>=<digest>" spec (e.g. "sha-256=ab…"); when set,
// the finished file is hashed and compared, failing with ErrChecksum on a
// mismatch (--checksum). Empty means no verification.
@@ -106,11 +73,6 @@ type Config struct {
// DryRun probes the resource — proving it exists and its size — but downloads
// nothing, then reports success (--dry-run).
DryRun bool
// CACert is a path to a PEM bundle of CA certificates used to verify HTTPS
// servers (--ca-certificate). Empty uses the system roots.
CACert string
// DisableIPv6 forces IPv4-only dialing (--disable-ipv6).
DisableIPv6 bool
}
// Download implements download.Download for one HTTP(S) file, fetched from one
@@ -119,11 +81,10 @@ type Config struct {
// connections.
type Download struct {
uris []string // mirror list; uris[0] is the primary (naming + resume key)
maxConns int // segment-worker cap = min(split, len(uris)*max-connection-per-server)
maxConns int // segment-worker cap = min(split, len(uris))
cfg Config
client *http.Client
limit []*rate.Limiter
jar *cookiejar.Jar // non-nil when cookies are in use, for save-cookies
// name and out are resolved in Run after the probe response is known (the
// final post-redirect URL and any Content-Disposition can change them). The
@@ -135,11 +96,6 @@ type Download struct {
out atomic.Pointer[string]
initErr error
// mu guards seenURLs, which records every URL we sent a request to so
// SaveCookies can ask the jar for the cookies of each contacted host.
mu sync.Mutex
seenURLs []*url.URL
// live counters, read by Stat from any goroutine
total int64 // -1 until known
completed int64
@@ -147,21 +103,6 @@ type Download struct {
conns int32 // active connections
}
// loadCAPool reads a PEM bundle of CA certificates for verifying HTTPS servers
// (--ca-certificate). An unreadable file or one containing no
// certificates is an error rather than a silent fall-back to the system roots.
func loadCAPool(path string) (*x509.CertPool, error) {
pem, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("ca-certificate %s: no certificates found", path)
}
return pool, nil
}
// primary is the canonical URL — used for naming and the resume sidecar so they
// stay stable regardless of which mirror served the bytes.
func (d *Download) primary() string { return d.uris[0] }
@@ -174,96 +115,27 @@ func (d *Download) mirror(n int) string { return d.uris[n%len(d.uris)] }
// New builds an HTTP download for uris, which must all serve identical content
// (mirrors). uris[0] is the primary.
func New(uris []string, cfg Config) *Download {
// --connect-timeout bounds only connection establishment; once data flows
// --timeout (the idle guard) takes over. The dial and the TLS handshake each
// get the full connectTimeout (Go applies it per phase), so an HTTPS connect
// can take up to ~2x connectTimeout in the worst case — there is no single
// shared connect budget.
connectTimeout := cfg.ConnectTimeout
if connectTimeout <= 0 {
connectTimeout = cfg.Timeout
}
var initErr error
// --ca-certificate: verify HTTPS servers against a custom CA bundle instead of
// the system roots. A bad bundle becomes an initErr so Run fails loudly.
tlsCfg := &tls.Config{InsecureSkipVerify: !cfg.CheckCert}
if cfg.CACert != "" {
if pool, err := loadCAPool(cfg.CACert); err != nil {
initErr = err
} else {
tlsCfg.RootCAs = pool
}
if cfg.MinSplit <= 0 {
cfg.MinSplit = minSplitSize
}
// --disable-ipv6: force IPv4 by dialing "tcp4"; a plain "tcp" would let the
// resolver hand back a v6 address.
dialer := &net.Dialer{Timeout: connectTimeout}
dialContext := dialer.DialContext
if cfg.DisableIPv6 {
dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
if network == "tcp" {
network = "tcp4"
}
return dialer.DialContext(ctx, network, addr)
}
}
// MaxConnsPerHost is the per-host cap (--max-connection-per-server): with
// mirrors the total worker count can exceed it, but no single host does.
perHost := cfg.MaxConnPerServer
if perHost < 1 {
perHost = 1
}
// One connect budget: --timeout bounds both establishing the connection and
// idle stalls once data flows. Proxies still come from the standard
// HTTP_PROXY/HTTPS_PROXY environment, and TLS uses the system roots.
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxConnsPerHost: perHost,
MaxIdleConnsPerHost: perHost,
MaxConnsPerHost: cfg.Split,
MaxIdleConnsPerHost: cfg.Split,
ResponseHeaderTimeout: cfg.Timeout,
TLSHandshakeTimeout: connectTimeout,
DialContext: dialContext,
TLSClientConfig: tlsCfg,
}
if cfg.Proxy != "" {
pu, err := url.Parse(cfg.Proxy)
switch {
case err != nil:
// A malformed proxy must fail loudly, not silently fall back to the
// environment proxy (or a direct connection) and leak the real IP.
if initErr == nil {
initErr = fmt.Errorf("proxy %q: %w", cfg.Proxy, err)
}
case pu.Host == "":
// "host:port" with no scheme parses to an opaque URL with no Host, which
// http.ProxyURL would route nowhere; require an explicit scheme.
if initErr == nil {
initErr = fmt.Errorf("proxy %q: missing scheme (use scheme://host:port)", cfg.Proxy)
}
default:
tr.Proxy = http.ProxyURL(pu)
}
}
// Seed the cookie jar from --load-cookies; a load error becomes an initErr so
// Run surfaces it rather than silently downloading uncredentialed.
var jar *cookiejar.Jar
if cfg.LoadCookies != "" || cfg.SaveCookies != "" {
if j, err := loadCookieJar(cfg.LoadCookies); err != nil {
if initErr == nil {
initErr = err
}
} else {
jar = j
}
TLSHandshakeTimeout: cfg.Timeout,
DialContext: (&net.Dialer{Timeout: cfg.Timeout}).DialContext,
}
var limit []*rate.Limiter
if cfg.OverallLimiter != nil {
limit = append(limit, cfg.OverallLimiter)
}
if cfg.Limit > 0 {
limit = append(limit, rate.NewLimiter(rate.Limit(cfg.Limit), download.LimiterBurst(cfg.Limit)))
}
// Seed a best-effort name from -o or the primary URL. The real name is
// finalised in Run after probe(), where Content-Disposition and the
@@ -283,31 +155,19 @@ func New(uris []string, cfg Config) *Download {
if len(uris) == 0 && initErr == nil {
initErr = errors.New("no URL to download")
}
// Worker count: --split connections are spread across the mirrors, capped
// at --max-connection-per-server per host, so the ceiling is
// min(split, M*max-conn-per-server). One mirror reduces to min(split, x).
// Worker count is --split: the file is cut into that many byte-range segments,
// each its own connection, whether served by one host or spread across mirrors.
maxConns := cfg.Split
if m := max(1, len(uris)) * perHost; maxConns > m {
maxConns = m
}
if maxConns < 1 {
maxConns = 1
}
// Only attach the jar when one was built: a typed-nil *cookiejar.Jar stored
// in the http.Client.Jar interface is non-nil and would panic on use.
client := &http.Client{Transport: tr}
if jar != nil {
client.Jar = jar
}
d := &Download{
uris: uris,
maxConns: maxConns,
cfg: cfg,
client: client,
client: &http.Client{Transport: tr},
limit: limit,
jar: jar,
total: -1,
initErr: initErr,
}
@@ -318,7 +178,8 @@ func New(uris []string, cfg Config) *Download {
func (d *Download) Name() string { return d.loadName() }
// Path returns the resolved output file path (used by --follow-torrent).
// Path returns the resolved output file path, so a fetched .torrent can be
// followed into a BitTorrent download of its content.
func (d *Download) Path() string { return d.loadOut() }
// name and out are read by Stat from the reporter goroutine while Run resolves
@@ -359,13 +220,12 @@ func (d *Download) setStatus(s download.Status) {
// probeResult carries everything probe() learned, so name finalisation can run
// after the request (the final URL and Content-Disposition are only known then).
type probeResult struct {
total int64
ranged bool
etag string
lastmod string
cdisp string // raw Content-Disposition header
finalURL *url.URL
notModified bool // server answered 304 to a conditional GET
total int64
ranged bool
etag string
lastmod string
cdisp string // raw Content-Disposition header
finalURL *url.URL
}
// Run probes the resource, finalises the output name, decides between a single
@@ -388,12 +248,6 @@ func (d *Download) Run(ctx context.Context) (err error) {
if err != nil {
return err
}
// --conditional-get: the server said our local copy is current, so there is
// nothing to download. Report success without touching the file.
if pr.notModified {
d.setStatus(download.Complete)
return nil
}
atomic.StoreInt64(&d.total, pr.total)
// Finalise name/out now that the post-redirect URL and Content-Disposition
@@ -421,25 +275,13 @@ func (d *Download) Run(ctx context.Context) (err error) {
return nil
}
// --lowest-speed-limit: cancel the transfer if its speed stays at or below
// the limit past a startup grace. The watcher cancels xferCtx and sets a
// flag so we can return the distinct "too slow" error rather than a bare
// cancellation.
out := d.loadOut()
xferCtx, stopGuard, tooSlow := d.speedGuard(ctx)
if !pr.ranged || pr.total <= 0 {
err = d.single(xferCtx, out)
err = d.single(ctx, out)
} else {
err = d.segmented(xferCtx, out, pr.total, pr.etag, pr.lastmod)
err = d.segmented(ctx, out, pr.total, pr.etag, pr.lastmod)
}
stopGuard()
if err != nil {
// Only translate to ErrTooSlow when the transfer actually ended in the
// guard's cancellation; a write error or mirror exhaustion that merely
// raced the guard firing keeps its own (real) cause and exit code.
if tooSlow() && errors.Is(err, context.Canceled) {
return fmt.Errorf("%s: %w (<= %d bytes/sec)", d.primary(), ErrTooSlow, d.cfg.LowestSpeedLimit)
}
return err
}
// --checksum: verify the finished file before declaring success, so a
@@ -449,12 +291,6 @@ func (d *Download) Run(ctx context.Context) (err error) {
return err
}
}
// -R / --remote-time: stamp the finished file with the server's mtime.
if d.cfg.RemoteTime && pr.lastmod != "" {
if t, err := http.ParseTime(pr.lastmod); err == nil {
os.Chtimes(out, t, t)
}
}
d.setStatus(download.Complete)
return nil
}
@@ -507,20 +343,6 @@ func (d *Download) resolveName(pr probeResult) error {
return nil
}
// backoff waits RetryWait between attempts, but never after the final attempt,
// and returns ctx.Err() if cancelled while waiting (item [20]).
func backoff(ctx context.Context, wait time.Duration, tries, attempt int) error {
if wait <= 0 || (tries != 0 && attempt >= tries-1) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
return nil
}
}
// retriesExhausted wraps the final cause once the retry budget is spent, so the
// caller — and the exit-code mapping in main — can still see whether it was DNS,
// a refused connection, or a timeout, rather than an opaque "too many retries".
@@ -538,12 +360,11 @@ func retriesExhausted(what string, tries int, cause error) error {
}
// withRetries runs attempt up to d.cfg.Tries times (0 = unlimited), the shared
// tries/backoff/fatal policy that probe, the single stream, and each segment all
// use so one transient DNS/connect/5xx does not fail the whole download (item
// [18]). It returns immediately on success (nil), on context cancellation, or on
// a fatal{} error (retrying cannot help), sleeps RetryWait between attempts, and
// wraps the final cause with retriesExhausted(what, ...) once the budget is
// spent so the underlying cause stays inspectable.
// tries/fatal policy that probe, the single stream, and each segment all use so
// one transient DNS/connect/5xx does not fail the whole download (item [18]). It
// returns immediately on success (nil), on context cancellation, or on a fatal{}
// error (retrying cannot help), and wraps the final cause with retriesExhausted
// once the budget is spent so the underlying cause stays inspectable.
func (d *Download) withRetries(ctx context.Context, what string, attempt func() error) error {
tries := d.cfg.Tries
var lastErr error
@@ -560,9 +381,6 @@ func (d *Download) withRetries(ctx context.Context, what string, attempt func()
if errors.As(err, &fe) {
return err // permanent: retrying cannot help
}
if err := backoff(ctx, d.cfg.RetryWait, tries, n); err != nil {
return err
}
}
return retriesExhausted(what, tries, lastErr)
}
@@ -614,15 +432,6 @@ func (d *Download) probe(ctx context.Context, uri string) (probeResult, error) {
if err != nil {
return probeResult{}, err
}
// --conditional-get: ask the server to skip the transfer when our local file
// is already current. Only done when there is no .got control file (an
// in-progress resume must not be short-circuited), using the local file's
// mtime for If-Modified-Since.
if out := d.loadOut(); d.cfg.ConditionalGet && !fileExists(controlPath(out)) {
if fi, err := os.Stat(out); err == nil {
req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
}
}
resp, err := d.client.Do(req)
if err != nil {
return probeResult{}, err
@@ -639,11 +448,6 @@ func (d *Download) probe(ctx context.Context, uri string) (probeResult, error) {
pr.finalURL = resp.Request.URL
}
if resp.StatusCode == http.StatusNotModified {
pr.notModified = true
return pr, nil
}
switch resp.StatusCode {
case http.StatusPartialContent:
// Content-Range: bytes 0-0/12345
@@ -864,74 +668,6 @@ func (d *Download) idleGuard(body io.Reader, cancel context.CancelFunc) (io.Read
return ir, func() { close(stop) }
}
// speedStartupGrace is how long a download may stay slow before the
// lowest-speed-limit watchdog starts checking it: a startup idle time of
// 10 seconds.
const speedStartupGrace = 10 * time.Second
// belowSpeed reports whether the bytes transferred between two cumulative
// d.completed samples (prev -> now over one window) put the download at or
// below limit. A negative delta is not a slow second: when a resumed Range is
// answered by a 200 the transfer restarts from offset 0, so d.completed jumps
// backward on that retry. Treating that backward jump as "below limit" would
// false-abort a healthy download, so a reset (delta < 0) reports false; the
// caller has already advanced its baseline, so the next full window measures the
// real speed. Only a genuine non-negative delta at or under the limit aborts.
func belowSpeed(prev, now, limit int64) bool {
delta := now - prev
if delta < 0 {
return false // counter reset, not a slow window
}
return delta <= limit
}
// speedGuard implements --lowest-speed-limit. It returns a child context that
// the watcher cancels (setting a flag) once the measured download speed has
// stayed at or below the limit past the startup grace, plus a stop func and a
// predicate reporting whether the watcher fired. When the limit is 0 it is a
// no-op passthrough.
//
// Speed is sampled from d.completed over a one-second window: a single slow
// second after the grace aborts the download as too slow.
func (d *Download) speedGuard(ctx context.Context) (context.Context, func(), func() bool) {
if d.cfg.LowestSpeedLimit <= 0 {
return ctx, func() {}, func() bool { return false }
}
child, cancel := context.WithCancel(ctx)
var fired atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
start := time.Now()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
last := atomic.LoadInt64(&d.completed)
for {
select {
case <-child.Done():
return
case <-ticker.C:
now := atomic.LoadInt64(&d.completed)
prev := last
last = now
if time.Since(start) < speedStartupGrace {
continue
}
if belowSpeed(prev, now, d.cfg.LowestSpeedLimit) {
fired.Store(true)
cancel()
return
}
}
}
}()
stop := func() {
cancel()
<-done
}
return child, stop, func() bool { return fired.Load() }
}
// fatal marks an error that retrying cannot fix. Unwrap exposes the wrapped
// error so errors.Is/As traverse a fatal{} — main's exit-code mapping relies on
// errors.Is(err, ErrNotFound) seeing through it (CONTRACT).
@@ -1134,13 +870,13 @@ func (d *Download) singleOnce(ctx context.Context, out, uri string, resumeFromDi
}
}
// saveInterval is how often the .got control file is rewritten so a crash can
// resume; on a clean finish it is removed.
const saveInterval = 60 * time.Second
func (d *Download) saveLoop(ctx context.Context, out string, total int64, etag, lastmod string, segs []seg, done chan<- struct{}) {
defer close(done)
interval := d.cfg.AutoSaveInterval
if interval <= 0 {
interval = 60 * time.Second // --auto-save-interval default
}
t := time.NewTicker(interval)
t := time.NewTicker(saveInterval)
defer t.Stop()
for {
select {
@@ -1171,13 +907,9 @@ func (d *Download) request(ctx context.Context, uri, rangeHdr string) (*http.Req
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", d.cfg.UserAgent)
if d.cfg.Referer != "" {
req.Header.Set("Referer", d.cfg.Referer)
}
if d.cfg.HTTPUser != "" {
req.SetBasicAuth(d.cfg.HTTPUser, d.cfg.HTTPPasswd)
}
req.Header.Set("User-Agent", userAgent)
// --header sets arbitrary request headers, applied last so they can override
// the User-Agent (or add a Referer, Authorization, Cookie, ...) when needed.
for _, h := range d.cfg.Headers {
if k, v, ok := strings.Cut(h, ":"); ok {
req.Header.Set(strings.TrimSpace(k), strings.TrimSpace(v))
@@ -1186,33 +918,9 @@ func (d *Download) request(ctx context.Context, uri, rangeHdr string) (*http.Req
if rangeHdr != "" {
req.Header.Set("Range", rangeHdr)
}
if d.jar != nil {
d.recordURL(req.URL)
}
return req, nil
}
// recordURL remembers a contacted URL so SaveCookies can later read back the
// jar's cookies for that host. Duplicates are tolerated; saveCookieJar dedups.
func (d *Download) recordURL(u *url.URL) {
d.mu.Lock()
d.seenURLs = append(d.seenURLs, u)
d.mu.Unlock()
}
// SaveCookies writes the client's cookie jar back to the --save-cookies file,
// if one was configured. It is a no-op when cookies are not in use. main calls
// it once per HTTP download on exit (cookies are saved at session end).
func (d *Download) SaveCookies() error {
if d.cfg.SaveCookies == "" || d.jar == nil {
return nil
}
d.mu.Lock()
urls := append([]*url.URL{}, d.seenURLs...)
d.mu.Unlock()
return saveCookieJar(d.jar, urls, d.cfg.SaveCookies)
}
// seedSegmentsFromPrefix marks the first prefix bytes of the file as already
// downloaded, segment by segment. A foreign partial file has no per-segment
// metadata, so the only thing we can trust is that bytes [0,prefix) are present;

View File

@@ -30,25 +30,24 @@ func TestMirrorPick(t *testing.T) {
}
}
// TestMaxConns checks the connection ceiling min(split, mirrors*x), floored
// at 1, since that decides how many segment workers fan out.
// TestMaxConns checks the connection ceiling: --split segment workers, floored
// at 1. Mirrors are alternate sources, not extra connections, so they do not
// raise the count.
func TestMaxConns(t *testing.T) {
cases := []struct{ split, x, mirrors, want int }{
{5, 1, 1, 1}, // default -x1 -s5: one connection
{5, 16, 1, 5}, // split is the ceiling on one server
{16, 16, 1, 16},
{5, 1, 3, 3}, // 3 mirrors * 1 per host < split
{16, 2, 3, 6}, // 3 mirrors * 2 per host < split
{0, 1, 1, 1}, // floor at 1
cases := []struct{ split, mirrors, want int }{
{5, 1, 5}, // split is the connection count on one server
{16, 1, 16},
{5, 3, 5}, // 3 mirrors, still split connections
{0, 1, 1}, // floor at 1
}
for _, c := range cases {
uris := make([]string, c.mirrors)
for i := range uris {
uris[i] = "http://h/x"
}
d := New(uris, Config{Split: c.split, MaxConnPerServer: c.x})
d := New(uris, Config{Split: c.split})
if d.maxConns != c.want {
t.Errorf("split=%d x=%d mirrors=%d: maxConns=%d, want %d", c.split, c.x, c.mirrors, d.maxConns, c.want)
t.Errorf("split=%d mirrors=%d: maxConns=%d, want %d", c.split, c.mirrors, d.maxConns, c.want)
}
}
}
@@ -77,8 +76,7 @@ func TestMirrorFallback(t *testing.T) {
// Primary is healthy (so the probe anchors size/validators), bad is a mirror
// that 404s every segment it's handed.
d := New([]string{good.URL + "/f.bin", bad.URL + "/f.bin"}, Config{
Dir: dir, Out: "f.bin", Split: 4, MaxConnPerServer: 2, MinSplit: 1,
Tries: 1, UserAgent: "got-test",
Dir: dir, Out: "f.bin", Split: 4, MinSplit: 1, Tries: 1,
})
if err := d.Run(context.Background()); err != nil {
t.Fatalf("Run with a 404 mirror should still complete via the healthy one: %v", err)
@@ -121,8 +119,7 @@ func TestMirrorDivergentRejected(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "f.bin")
d := New([]string{primary.URL + "/f.bin", div.URL + "/f.bin"}, Config{
Dir: dir, Out: "f.bin", Split: 4, MaxConnPerServer: 2, MinSplit: 1,
Tries: 1, UserAgent: "got-test",
Dir: dir, Out: "f.bin", Split: 4, MinSplit: 1, Tries: 1,
})
if err := d.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)

View File

@@ -43,11 +43,9 @@ func TestSingleShortBody(t *testing.T) {
out := filepath.Join(dir, "file.bin")
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
Tries: 1, // a single attempt so the retryable short read surfaces, not loops
UserAgent: "got-test",
Dir: dir,
Out: "file.bin",
Tries: 1, // a single attempt so the retryable short read surfaces, not loops
})
err := d.single(context.Background(), out)
@@ -75,11 +73,9 @@ func TestSingleCompleteFullBody(t *testing.T) {
out := filepath.Join(dir, "file.bin")
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
Tries: 1,
UserAgent: "got-test",
Dir: dir,
Out: "file.bin",
Tries: 1,
})
if err := d.single(context.Background(), out); err != nil {

View File

@@ -1,32 +0,0 @@
package httpdl
import "testing"
// TestBelowSpeed covers the speedGuard decision, including the counter-reset
// case (item [1]): d.completed can jump backward when singleOnce rewrites it
// with an absolute StoreInt64 and a resumed Range is answered by a 200 that
// resets the offset to 0. A backward jump must NOT be read as a slow window, or
// a healthy download would be false-aborted.
func TestBelowSpeed(t *testing.T) {
const limit = 1000
cases := []struct {
name string
prev, now int64
want bool
}{
{"fast", 0, 5000, false},
{"exactly at limit aborts", 1000, 2000, true},
{"just under limit aborts", 1000, 1999, true},
{"just over limit ok", 1000, 2001, false},
{"stalled aborts", 4242, 4242, true},
{"counter reset is not slow", 1_000_000, 0, false},
{"small backward jump is not slow", 5000, 4500, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := belowSpeed(c.prev, c.now, limit); got != c.want {
t.Errorf("belowSpeed(%d, %d, %d) = %v; want %v", c.prev, c.now, limit, got, c.want)
}
})
}
}