Compare commits

..

10 Commits

Author SHA1 Message Date
81c44d9ec7 httpdl/cli: drop --auto-split; classify errors by type, not message text
Two Pike cleanups.

--auto-split was a got-only third connection-count policy (beside -x and -s)
that invented rather than matched aria2. Removing it also retires the now-dead
autoConns/maxAutoConns and the per-host transport cap that existed only to scale
for it; min(split, M*-x) is the sole policy again.

main's exit-code mapping fell back to substring-matching third-party error text
(strings.Contains "connection refused"/"timeout"/...), which rots when a
dependency rewords a message. The typed checks (errors.Is on the syscall errno,
*net.DNSError, net.Error.Timeout, context.DeadlineExceeded) already cover the
real stdlib errors -- verified end to end: refused -> 6, bad host -> 19. The two
cases that genuinely needed the text match are our own errors, now typed
sentinels: httpdl.ErrTimeout (idle timeout) and the bt metadata timeout wrapping
context.DeadlineExceeded.
2026-06-20 23:55:39 +09:00
270812de3e httpdl: drop work-stealing segment pool for one worker per segment
The pool let an idle worker steal the back half of a slower in-flight
segment, behind a mutex that also serialized every advance. At the default
-x 1 it never fired, and it carried the trickiest invariant in the tree
(minSplit >= readBuf keeps an owner's in-flight write out of the stolen tail).

makeSegments already caps the segment count at the connection count, so the
segments map one-to-one onto workers: give each its own goroutine, advance
written with a plain atomic add, and snapshot the fixed slice with no lock.
pool/newPool/acquire/steal/advance and the seg.owned field all go away.

Net -156 lines. Behaviour at the user's flags is unchanged, except a slow
mirror's tail segment is no longer rebalanced onto idle connections.
2026-06-20 23:43:15 +09:00
65104ade92 httpdl: fix progress-reporter race on name/out; harden mirror & resume validation
Findings from a Rob-Pike-lens review (bugs/races/network), each verified
against the code before fixing:

- httpdl: name/out are now atomic.Pointer[string] -- the engine publishes a
  download to the reporter before Run resolves the name, so Stat raced the
  write (bt already did this)
- httpdl: a malformed --proxy fails loudly instead of silently bypassing it
- httpdl: a 206 must carry a matching Content-Range; a 200 in segmented mode is
  fatal so it fails over instead of burning the retry budget
- httpdl: single-stream mirror failover validates the range before appending;
  ErrTooSlow only when the error is a real ctx cancellation
- httpdl: idle guard tracks progress by timestamp (no Reset/Stop race, no
  sticky fired flag)
- httpdl/control: reject a resume file whose segments don't tile [0,total)
- bt: clamp the listen-port range; verify on-disk data before choosing pieces
  under --check-integrity
- cli: reject size overflow; show --seed-time=MIN; clamp --select-file range
- progress/engine/main: clamp ETA against int64 overflow; show queued
  downloads as waiting; join the reporter on exit instead of a 20ms sleep
2026-06-20 23:28:26 +09:00
db73b51e0d gitignore: ignore downloaded media so an in-tree run can't commit content
running got in the repo root drops downloaded files next to the source;
torrents/control files were already ignored, add common media (mkv/mp4/
avi/mov/webm/iso/mp3/flac) so anime/ISO downloads don't show up as
untracked and can't be committed by accident.
2026-06-20 21:54:29 +09:00
27a5cece41 cli: show -x/-s/-k in basic help, matching aria2
max-connection-per-server, split and min-split-size were tagged HTTP, so
they only appeared under --help=all. aria2 tags all three TAG_BASIC; tag
them Basic too so they show in the default --help, where users expect the
core connection knobs.
2026-06-20 21:53:33 +09:00
d9de63f451 httpdl: unify completed counter and dedupe mirror loop; engine: harden untrack
From a Rob Pike pass over the tree:
- singleOnce adds deltas to d.completed like pump does, instead of
  storing absolute offsets, so the counter has one write discipline.
- one overMirrors helper replaces the three copy-pasted mirror-fallover
  loops in probeRetry, fetchSeg and single.
- failedDownload becomes a pointer, the only value-type Download impl, so
  Engine.untrack's == is always pointer identity and can't compare the
  fields of a value (keying on Stat().ID is out — a torrent's ID changes
  from source to infohash once metadata loads).
2026-06-20 21:12:07 +09:00
11734af7fb bt: filter anacrolix logs to errors so warnings don't corrupt the readout
webseed/peer/tracker warnings (the 403/429 chatter) go through slog, the
rest through the legacy analog logger; both default to Warning, which
interleaves with and corrupts the live progress block. Filter both to
Error and above so only genuine errors reach stderr. quietSlogger is a
small testable helper; anacrolix/log becomes a direct dependency.
2026-06-20 21:11:57 +09:00
16cbb97748 progress: show multiple downloads as a bar table
lineMany crammed every download onto one line as name+percent, dropping
the speed column that answers "which one is stalled" and producing ugly
double brackets for names like "[Gecko]". render N>1 as a redrawn block:
one row each (name, bar, percent, DL speed, ETA/stalled), a header with
the active count and totals, falling back to a single summary line when
the table would overflow the window. one download keeps its dashboard.
2026-06-20 19:45:49 +09:00
ee66235c36 main: dedupe followed .torrent files by infohash
a .torrent fetched over http is added to the shared client in a second
pass (followUps); two that name the same torrent would share one torrent
on the refcount-less client, and the first to finish would Drop it out
from under the other, stranding it at 0%. dedupe the followed files by
infohash, the guard build() already applies to pass-1 sources.
2026-06-20 19:28:32 +09:00
3d9ea7ce8b httpdl: add segment work-stealing and --auto-split
an idle worker now steals the back half of the busiest in-flight segment
instead of exiting, so one slow segment no longer drains alone over a single
connection. control file persists stolen tails; resume re-tiles.

add --auto-split (got-only, opt-in): derive the connection count from file
size (one per min-split-size, up to 16), overriding -x/-s. default stays
aria2-faithful at 1 connection for a single server.
2026-06-19 14:01:59 +09:00
18 changed files with 172 additions and 185 deletions

View File

@@ -15,17 +15,15 @@ Needs Go 1.25+.
## Usage ## Usage
```sh ```sh
# HTTP download: parallel out of the box (5 connections); -s turns it up # HTTP download with 16 connections
got https://example.com/big.iso got -x16 -s16 https://example.com/big.iso
got -s16 https://example.com/big.iso
# resume an interrupted download # resume an interrupted download
got -c https://example.com/big.iso got -c https://example.com/big.iso
# one file from several mirrors (connections spread across servers, # one file from several mirrors (connections spread across servers,
# a dead mirror falls over); -Z downloads them as separate files instead. # a dead mirror falls over); -Z downloads them as separate files instead
# -x caps connections per server when mirroring. got -x4 https://a.example/big.iso https://b.example/big.iso
got -s16 -x4 https://a.example/big.iso https://b.example/big.iso
# a torrent or magnet; seed for 30 minutes after finishing # a torrent or magnet; seed for 30 minutes after finishing
got --seed-time=30 ubuntu.torrent got --seed-time=30 ubuntu.torrent
@@ -44,9 +42,9 @@ Run `got --help` (or `--help=all`) for every option.
| `-d, --dir` | output directory | `.` | | `-d, --dir` | output directory | `.` |
| `-o, --out` | output filename | from URL | | `-o, --out` | output filename | from URL |
| `-c, --continue` | resume a partial download | false | | `-c, --continue` | resume a partial download | false |
| `-s, --split` | connections per download (the speed dial) | 5 | | `-x, --max-connection-per-server` | connections to one server (116) | 1 |
| `-x, --max-connection-per-server` | cap connections per server (116) | 1 (= split when unset) | | `-s, --split` | split a download into N connections | 5 |
| `-j, --max-concurrent-downloads` | files downloaded at once | 5 | | `-j, --max-concurrent-downloads` | downloads at once | 5 |
| `--max-overall-download-limit` | global speed cap | 0 (off) | | `--max-overall-download-limit` | global speed cap | 0 (off) |
| `--checksum` | verify the finished file: `TYPE=DIGEST` (sha-256, sha-1, …) | | | `--checksum` | verify the finished file: `TYPE=DIGEST` (sha-256, sha-1, …) | |
| `--seed-time` | minutes to seed after a torrent finishes | by ratio | | `--seed-time` | minutes to seed after a torrent finishes | by ratio |

View File

@@ -19,6 +19,7 @@ import (
"time" "time"
alog "github.com/anacrolix/log" alog "github.com/anacrolix/log"
missinggo "github.com/anacrolix/missinggo/v2"
"github.com/anacrolix/torrent" "github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/metainfo"
"github.com/hanbok/got/download" "github.com/hanbok/got/download"
@@ -171,11 +172,10 @@ func NewClient(cfg ClientConfig) (*torrent.Client, error) {
return cl, nil return cl, nil
} }
// isAddrInUse reports whether err is an "address already in use" bind failure. // isAddrInUse reports whether err is an "address already in use" bind failure,
// (anacrolix/missinggo's IsAddrInUse is this exact string match, so we just do // matching either missinggo's helper or the raw string the library wraps.
// it directly rather than carry the import for it.)
func isAddrInUse(err error) bool { func isAddrInUse(err error) bool {
return err != nil && strings.Contains(err.Error(), "address already in use") return err != nil && (missinggo.IsAddrInUse(err) || strings.Contains(err.Error(), "address already in use"))
} }
// Options are the per-download settings. // Options are the per-download settings.
@@ -184,6 +184,7 @@ type Options struct {
SeedTime time.Duration // how long to seed (0 with SeedTimeSet means no seeding) SeedTime time.Duration // how long to seed (0 with SeedTimeSet means no seeding)
SeedRatio float64 // stop seeding at this ratio; checked alongside SeedTime SeedRatio float64 // stop seeding at this ratio; checked alongside SeedTime
StopTimeout time.Duration // abort if no download progress for this long (0 = off) StopTimeout time.Duration // abort if no download progress for this long (0 = off)
MetaTimeout time.Duration // abort a magnet that can't fetch metadata in this long (0 = off)
SelectFiles map[int]bool // 1-based file indexes to fetch; empty = all SelectFiles map[int]bool // 1-based file indexes to fetch; empty = all
CheckIntegrity bool // re-verify data against piece hashes before downloading CheckIntegrity bool // re-verify data against piece hashes before downloading
DryRun bool // fetch metadata only, then stop without downloading (--dry-run) DryRun bool // fetch metadata only, then stop without downloading (--dry-run)
@@ -313,8 +314,8 @@ func (d *Download) Run(ctx context.Context) (err error) {
} }
// --check-integrity: re-hash the existing on-disk data BEFORE arming the // --check-integrity: re-hash the existing on-disk data BEFORE arming the
// request loop, so already-good pieces are not re-requested from peers // request loop, so already-good pieces are not re-requested from peers (aria2
// (verify first, then fetch only what is missing). // verifies first, then fetches only what is missing).
if d.opts.CheckIntegrity { if d.opts.CheckIntegrity {
if err := t.VerifyDataContext(ctx); err != nil { if err := t.VerifyDataContext(ctx); err != nil {
return err return err
@@ -373,27 +374,27 @@ func (d *Download) choose(t *torrent.Torrent) error {
return nil return nil
} }
// metadataTimeout bounds the magnet metadata-fetch phase. A magnet with no // awaitInfo blocks until the torrent metadata arrives. It gives up after
// reachable peers — a dead link, or UDP trackers behind a firewall with no DHT — // --bt-metadata-timeout (default 60s) so a magnet with no reachable peers — a
// would otherwise hang forever, so awaitInfo gives up after this fixed window. // dead link, or UDP trackers behind a firewall with no DHT — fails fast instead
// It is not a user-facing flag — 60s is a generous ceiling that a healthy swarm // of hanging forever. Setting --bt-metadata-timeout=0 disables the timeout and
// clears in well under a second. // waits indefinitely.
const metadataTimeout = 60 * time.Second
// awaitInfo blocks until the torrent metadata arrives, giving up after
// metadataTimeout so a peerless magnet fails fast instead of hanging forever.
func (d *Download) awaitInfo(ctx context.Context, t *torrent.Torrent) error { func (d *Download) awaitInfo(ctx context.Context, t *torrent.Torrent) error {
tm := time.NewTimer(metadataTimeout) var deadline <-chan time.Time
if d.opts.MetaTimeout > 0 {
tm := time.NewTimer(d.opts.MetaTimeout)
defer tm.Stop() defer tm.Stop()
deadline = tm.C
}
select { select {
case <-t.GotInfo(): case <-t.GotInfo():
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-tm.C: case <-deadline:
// Wrap DeadlineExceeded so the exit-code mapping classifies it as a timeout // Wrap DeadlineExceeded so the exit-code mapping classifies it as a timeout
// (2) via errors.Is, rather than matching on the message text. // (2) via errors.Is, rather than matching on the message text.
return fmt.Errorf("timed out fetching metadata after %s: %w", metadataTimeout, context.DeadlineExceeded) return fmt.Errorf("timed out fetching metadata after %s: %w", d.opts.MetaTimeout, context.DeadlineExceeded)
} }
} }

View File

@@ -89,31 +89,24 @@ func flagLabel(o *Opt) string {
b.WriteString(" ") b.WriteString(" ")
} }
b.WriteString("--" + o.Long) b.WriteString("--" + o.Long)
if m := metavar(o); m != "" { switch o.Kind {
b.WriteString("=" + m) case Bool:
// no argument shown
case Size:
b.WriteString("=SIZE")
case Int:
b.WriteString("=N")
case Float:
// seed-time is a count of minutes; only seed-ratio is an actual ratio.
if o.Long == "seed-time" {
b.WriteString("=MIN")
} else {
b.WriteString("=RATIO")
}
case Enum:
b.WriteString("=" + strings.Join(o.Enum, "|"))
default:
b.WriteString("=VAL")
} }
return b.String() return b.String()
} }
// metavar is the placeholder shown after a value-taking flag, e.g. the "N" in
// "--split=N". An explicit Opt.Metavar wins; otherwise it derives from the Kind.
// Bool takes no value, so its metavar is empty and no "=ARG" is printed.
func metavar(o *Opt) string {
if o.Metavar != "" {
return o.Metavar
}
switch o.Kind {
case Bool:
return ""
case Size:
return "SIZE"
case Int:
return "N"
case Float:
return "RATIO"
case Enum:
return strings.Join(o.Enum, "|")
default:
return "VAL"
}
}

View File

@@ -50,10 +50,6 @@ type Opt struct {
Kind Kind Kind Kind
Default string Default string
Help string Help string
// Metavar overrides the "=PLACEHOLDER" shown after the flag in --help. Empty
// means derive it from Kind (N, SIZE, RATIO, ...). Set it only when the
// Kind-derived default would mislead, e.g. seed-time is minutes, not a ratio.
Metavar string
Tag Tag Tag Tag
Enum []string // valid values when Kind == Enum Enum []string // valid values when Kind == Enum
// Min is the inclusive lower Int/Size bound. The zero value enforces a // Min is the inclusive lower Int/Size bound. The zero value enforces a
@@ -64,38 +60,38 @@ type Opt struct {
Max int64 Max int64
} }
// options is the single source of truth: a focused, practical subset. The Tag // options is the single source of truth: a focused, practical subset.
// decides which --help group an option shows under; the Basic group is the bare
// `got --help` and is kept to the handful of flags that change WHAT happens, not
// how fast. Every other flag still works — it just lives behind --help=<group>.
var options = []Opt{ var options = []Opt{
// --- basic --- the outcome-changing core shown by a bare `got --help`. // --- basic ---
{Long: "dir", Short: 'd', Kind: Str, Help: "directory to store downloaded files", Tag: Basic}, {Long: "dir", Short: 'd', Kind: Str, Help: "directory to store downloaded files", Tag: Basic},
{Long: "out", Short: 'o', Kind: Str, Help: "output filename for the download", Tag: Basic}, {Long: "out", Short: 'o', Kind: Str, Help: "output filename for the download", Tag: Basic},
{Long: "input-file", Short: 'i', Kind: Str, Help: "read URIs line by line from FILE (- for stdin)", Tag: Basic}, {Long: "input-file", Short: 'i', Kind: Str, Help: "read URIs line by line from FILE (- for stdin)", Tag: Basic},
{Long: "max-concurrent-downloads", Short: 'j', Kind: Int, Default: "5", Min: 1, Help: "how many files to download at once (not connections within one file)", Tag: Basic},
{Long: "continue", Short: 'c', Kind: Bool, Default: "false", Help: "resume a partially downloaded file", Tag: Basic}, {Long: "continue", Short: 'c', Kind: Bool, Default: "false", Help: "resume a partially downloaded file", Tag: Basic},
{Long: "split", Short: 's', Kind: Int, Default: "5", Min: 1, Help: "connections per download — the speed dial (default 5; e.g. -s16 for 16)", Tag: Basic}, {Long: "max-concurrent-downloads", Short: 'j', Kind: Int, Default: "5", Min: 1, Help: "max number of parallel downloads", Tag: Basic},
{Long: "max-overall-download-limit", Kind: Size, Default: "0", Help: "global download speed limit (0 = unlimited)", Tag: Basic}, {Long: "check-integrity", Short: 'V', Kind: Bool, Default: "false", Help: "re-verify torrent data against piece hashes (BitTorrent)", Tag: Basic},
{Long: "checksum", Kind: Str, Help: "verify the finished file: TYPE=DIGEST, e.g. sha-256=<hex> (md5, sha-1, sha-224, sha-256, sha-384, sha-512)", Tag: Basic},
{Long: "show-files", Short: 'S', Kind: Bool, Default: "false", Help: "list files in a torrent and exit", Tag: Basic}, {Long: "show-files", Short: 'S', Kind: Bool, Default: "false", Help: "list files in a torrent and exit", Tag: Basic},
{Long: "allow-overwrite", Kind: Bool, Default: "false", Help: "overwrite an existing file", Tag: Basic},
{Long: "auto-file-renaming", Kind: Bool, Default: "true", Help: "rename file (.1, .2, ...) if it already exists", Tag: Basic},
{Long: "max-overall-download-limit", Kind: Size, Default: "0", Help: "global download speed limit (0 = unlimited)", Tag: Basic},
{Long: "max-download-limit", Kind: Size, Default: "0", Help: "per-download speed limit (0 = unlimited)", Tag: Basic},
{Long: "quiet", Short: 'q', Kind: Bool, Default: "false", Help: "suppress the progress readout", Tag: Basic}, {Long: "quiet", Short: 'q', Kind: Bool, Default: "false", Help: "suppress the progress readout", Tag: Basic},
{Long: "human-readable", Kind: Bool, Default: "true", Help: "show sizes as Ki/Mi/Gi", Tag: Basic},
// --- http --- // --- http ---
{Long: "max-connection-per-server", Short: 'x', Kind: Int, Default: "1", Min: 1, Max: 16, Help: "cap connections to any single server (1-16); limits --split per host, mainly for mirrors", Tag: HTTP}, {Long: "max-connection-per-server", Short: 'x', Kind: Int, Default: "1", Min: 1, Max: 16, Help: "max connections to one server (1-16)", Tag: Basic},
{Long: "min-split-size", Short: 'k', Kind: Size, Default: "20M", Min: 1 << 20, Max: 1 << 30, Help: "do not split a piece smaller than SIZE (1M-1024M)", Tag: HTTP}, {Long: "split", Short: 's', Kind: Int, Default: "5", Min: 1, Help: "split a download into N connections; actual connections are min(max-connection-per-server, split), and -x defaults to 1", Tag: Basic},
{Long: "max-download-limit", Kind: Size, Default: "0", Help: "per-download speed limit (0 = unlimited)", Tag: HTTP}, {Long: "min-split-size", Short: 'k', Kind: Size, Default: "20M", Min: 1 << 20, Max: 1 << 30, Help: "do not split a piece smaller than SIZE (1M-1024M)", Tag: Basic},
{Long: "force-sequential", Short: 'Z', Kind: Bool, Default: "false", Help: "download each command-line URI as its own file instead of mirroring them", Tag: HTTP}, {Long: "force-sequential", Short: 'Z', Kind: Bool, Default: "false", Help: "download each command-line URI as its own file instead of mirroring them", Tag: HTTP},
{Long: "max-tries", Short: 'm', Kind: Int, Default: "5", Min: 0, Help: "max retries per segment (0 = unlimited)", Tag: HTTP}, {Long: "max-tries", Short: 'm', Kind: Int, Default: "5", Min: 0, Help: "max retries per segment (0 = unlimited)", Tag: HTTP},
{Long: "timeout", Short: 't', Kind: Int, Default: "60", Min: 1, Help: "connection timeout in seconds", Tag: HTTP}, {Long: "timeout", Short: 't', Kind: Int, Default: "60", Min: 1, Help: "connection timeout in seconds", Tag: HTTP},
{Long: "connect-timeout", Kind: Int, Default: "60", Min: 1, Help: "connection establishment timeout in seconds", Tag: HTTP},
{Long: "retry-wait", Kind: Int, Default: "0", Min: 0, Max: 600, Help: "seconds to wait between retries (0 = no wait)", Tag: HTTP},
{Long: "header", Kind: List, Help: "append an extra HTTP header (repeatable)", Tag: HTTP}, {Long: "header", Kind: List, Help: "append an extra HTTP header (repeatable)", Tag: HTTP},
{Long: "user-agent", Short: 'U', Kind: Str, Default: "got/1.0", Help: "HTTP User-Agent", Tag: HTTP}, {Long: "user-agent", Short: 'U', Kind: Str, Default: "got/1.0", Help: "HTTP User-Agent", Tag: HTTP},
{Long: "referer", Kind: Str, Help: "HTTP Referer header", Tag: HTTP}, {Long: "referer", Kind: Str, Help: "HTTP Referer header", Tag: HTTP},
{Long: "all-proxy", Kind: Str, Help: "proxy for all protocols (host:port)", Tag: HTTP}, {Long: "all-proxy", Kind: Str, Help: "proxy for all protocols (host:port)", Tag: HTTP},
{Long: "check-certificate", Kind: Bool, Default: "true", Help: "verify the server's TLS certificate", Tag: HTTP}, {Long: "check-certificate", Kind: Bool, Default: "true", Help: "verify the server's TLS certificate", Tag: HTTP},
{Long: "ca-certificate", Kind: Str, Help: "verify HTTPS servers against the CA certificates in FILE (PEM)", Tag: HTTP}, {Long: "ca-certificate", Kind: Str, Help: "verify HTTPS servers against the CA certificates in FILE (PEM)", Tag: HTTP},
{Long: "retry-wait", Kind: Int, Default: "0", Min: 0, Max: 600, Help: "seconds to wait between retries (0 = no wait)", Tag: HTTP},
{Long: "connect-timeout", Kind: Int, Default: "60", Min: 1, Help: "connection establishment timeout in seconds", Tag: HTTP},
{Long: "load-cookies", Kind: Str, Help: "load Cookies from FILE (Netscape/Mozilla format)", Tag: HTTP}, {Long: "load-cookies", Kind: Str, Help: "load Cookies from FILE (Netscape/Mozilla format)", Tag: HTTP},
{Long: "save-cookies", Kind: Str, Help: "save Cookies to FILE on exit", Tag: HTTP}, {Long: "save-cookies", Kind: Str, Help: "save Cookies to FILE on exit", Tag: HTTP},
{Long: "conditional-get", Kind: Bool, Default: "false", Help: "download only if the remote file is newer than the local one", Tag: HTTP}, {Long: "conditional-get", Kind: Bool, Default: "false", Help: "download only if the remote file is newer than the local one", Tag: HTTP},
@@ -103,13 +99,14 @@ var options = []Opt{
{Long: "http-user", Kind: Str, Help: "HTTP basic-auth user", Tag: HTTP}, {Long: "http-user", Kind: Str, Help: "HTTP basic-auth user", Tag: HTTP},
{Long: "http-passwd", Kind: Str, Help: "HTTP basic-auth password", Tag: HTTP}, {Long: "http-passwd", Kind: Str, Help: "HTTP basic-auth password", Tag: HTTP},
{Long: "lowest-speed-limit", Kind: Size, Default: "0", Help: "abort if speed stays below SIZE bytes/sec (0 = off)", Tag: HTTP}, {Long: "lowest-speed-limit", Kind: Size, Default: "0", Help: "abort if speed stays below SIZE bytes/sec (0 = off)", Tag: HTTP},
{Long: "checksum", Kind: Str, Help: "verify the finished file: TYPE=DIGEST, e.g. sha-256=<hex> (md5, sha-1, sha-224, sha-256, sha-384, sha-512)", Tag: HTTP},
// --- bittorrent --- // --- bittorrent ---
{Long: "torrent-file", Short: 'T', Kind: Str, Help: "path to a .torrent file", Tag: BitTorrent}, {Long: "torrent-file", Short: 'T', Kind: Str, Help: "path to a .torrent file", Tag: BitTorrent},
{Long: "check-integrity", Short: 'V', Kind: Bool, Default: "false", Help: "re-verify torrent data against piece hashes", Tag: BitTorrent}, {Long: "seed-time", Kind: Float, Help: "minutes to seed after completion (0 = no seeding)", Tag: BitTorrent},
{Long: "seed-time", Kind: Float, Metavar: "MIN", Help: "minutes to seed after completion (0 = no seeding)", Tag: BitTorrent},
{Long: "seed-ratio", Kind: Float, Default: "1.0", Help: "stop seeding at this share ratio (0 = unlimited)", Tag: BitTorrent}, {Long: "seed-ratio", Kind: Float, Default: "1.0", Help: "stop seeding at this share ratio (0 = unlimited)", Tag: BitTorrent},
{Long: "bt-stop-timeout", Kind: Int, Default: "0", Min: 0, Help: "stop a torrent with no download progress for N seconds (0 = off)", Tag: BitTorrent}, {Long: "bt-stop-timeout", Kind: Int, Default: "0", Min: 0, Help: "stop a torrent with no download progress for N seconds (0 = off)", Tag: BitTorrent},
{Long: "bt-metadata-timeout", Kind: Int, Default: "60", Min: 0, Help: "stop a magnet that can't fetch metadata in N seconds (0 = off)", Tag: BitTorrent},
{Long: "listen-port", Kind: Str, Default: "6881-6999", Help: "TCP port (range) for incoming peers", Tag: BitTorrent}, {Long: "listen-port", Kind: Str, Default: "6881-6999", Help: "TCP port (range) for incoming peers", Tag: BitTorrent},
{Long: "enable-dht", Kind: Bool, Default: "true", Help: "enable the BitTorrent DHT", Tag: BitTorrent}, {Long: "enable-dht", Kind: Bool, Default: "true", Help: "enable the BitTorrent DHT", Tag: BitTorrent},
{Long: "bt-max-peers", Kind: Int, Default: "55", Min: 0, Help: "max peers per torrent (0 = unlimited)", Tag: BitTorrent}, {Long: "bt-max-peers", Kind: Int, Default: "55", Min: 0, Help: "max peers per torrent (0 = unlimited)", Tag: BitTorrent},
@@ -119,10 +116,7 @@ var options = []Opt{
{Long: "follow-torrent", Kind: Bool, Default: "true", Help: "after fetching a .torrent over HTTP, download its content", Tag: BitTorrent}, {Long: "follow-torrent", Kind: Bool, Default: "true", Help: "after fetching a .torrent over HTTP, download its content", Tag: BitTorrent},
// --- advanced --- // --- advanced ---
{Long: "allow-overwrite", Kind: Bool, Default: "false", Help: "overwrite an existing file", Tag: Advanced}, {Long: "file-allocation", Short: 'a', Kind: Enum, Default: "prealloc", Enum: []string{"none", "prealloc", "trunc", "falloc"}, Help: "how to allocate disk space", Tag: Advanced},
{Long: "auto-file-renaming", Kind: Bool, Default: "true", Help: "rename file (.1, .2, ...) if it already exists", Tag: Advanced},
{Long: "human-readable", Kind: Bool, Default: "true", Help: "show sizes as Ki/Mi/Gi", Tag: Advanced},
{Long: "file-allocation", Short: 'a', Kind: Enum, Default: "prealloc", Enum: []string{"none", "prealloc", "trunc", "falloc"}, Help: "how to reserve disk space (prealloc and falloc are identical here)", Tag: Advanced},
{Long: "dry-run", Kind: Bool, Default: "false", Help: "check that the file is available but do not download it", Tag: Advanced}, {Long: "dry-run", Kind: Bool, Default: "false", Help: "check that the file is available but do not download it", Tag: Advanced},
{Long: "stop", Kind: Int, Default: "0", Min: 0, Help: "stop the program after N seconds (0 = off)", Tag: Advanced}, {Long: "stop", Kind: Int, Default: "0", Min: 0, Help: "stop the program after N seconds (0 = off)", Tag: Advanced},
{Long: "disable-ipv6", Kind: Bool, Default: "false", Help: "disable IPv6 (force IPv4-only connections)", Tag: Advanced}, {Long: "disable-ipv6", Kind: Bool, Default: "false", Help: "disable IPv6 (force IPv4-only connections)", Tag: Advanced},

View File

@@ -27,20 +27,18 @@ func (o *Options) IsSet(name string) bool { return o.set[name] }
// Str returns the raw string value (empty if unset and no default). // Str returns the raw string value (empty if unset and no default).
func (o *Options) Str(name string) string { return o.vals[name] } func (o *Options) Str(name string) string { return o.vals[name] }
// boolWords is the accepted vocabulary for boolean options: exactly true and // boolWords is the single accepted vocabulary for boolean options, mapping each
// false, nothing else. boolWord is the single consult point — the Bool reader, // recognised spelling to its truth value. The Bool reader, truthy, and
// validate(), and the no-conf bootstrap all go through it so exactly the words // validate() all consult it, so exactly the words that validate are honoured
// that validate are honoured. // (no "accepted by the reader but rejected by validate" surprises like --x=on).
var boolWords = map[string]bool{ var boolWords = map[string]bool{
"true": true, "true": true, "yes": true, "1": true, "on": true,
"false": false, "false": false, "no": false, "0": false, "off": false,
} }
// boolWord reports a value's truth and whether it is a recognised boolean word. // boolWord reports a value's truth and whether it is a recognised boolean word.
// Whitespace is trimmed, but case is significant: "True"/"TRUE" are rejected, so
// the match is against exactly "true"/"false".
func boolWord(s string) (val, ok bool) { func boolWord(s string) (val, ok bool) {
val, ok = boolWords[strings.TrimSpace(s)] val, ok = boolWords[strings.ToLower(strings.TrimSpace(s))]
return val, ok return val, ok
} }
@@ -50,11 +48,13 @@ func (o *Options) Bool(name string) bool {
return val return val
} }
// Int returns the value as an int, or 0 if empty/invalid. The value has already // Int returns the value as an int, or 0 if empty/invalid.
// passed validate(), so a parse failure here is unreachable in normal flow. func (o *Options) Int(name string) int { return int(o.Int64(name)) }
func (o *Options) Int(name string) int {
// Int64 returns the value as an int64, or 0 if empty/invalid.
func (o *Options) Int64(name string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(o.vals[name]), 10, 64) n, _ := strconv.ParseInt(strings.TrimSpace(o.vals[name]), 10, 64)
return int(n) return n
} }
// Float returns the value as a float64, or 0 if empty/invalid. // Float returns the value as a float64, or 0 if empty/invalid.

View File

@@ -41,7 +41,7 @@ func Parse(args []string) (*Result, error) {
opts := newOptions() opts := newOptions()
applyDefaults(opts) applyDefaults(opts)
if noConf, _ := boolWord(cmdVals["no-conf"]); !noConf { if !truthy(cmdVals["no-conf"]) {
explicit := cmdVals["conf-path"] explicit := cmdVals["conf-path"]
path, fromUser := confPath(explicit) path, fromUser := confPath(explicit)
if err := applyConfig(opts, path, fromUser); err != nil { if err := applyConfig(opts, path, fromUser); err != nil {
@@ -138,23 +138,14 @@ func parseArgs(args []string) (vals map[string]string, uris []string, action Act
// accumulate stores a value, joining repeatable List options with newlines. // accumulate stores a value, joining repeatable List options with newlines.
func accumulate(vals map[string]string, o *Opt, val string) { func accumulate(vals map[string]string, o *Opt, val string) {
if o.Kind == List { if o.Kind == List {
vals[o.Long] = appendList(vals[o.Long], val) if prev, ok := vals[o.Long]; ok {
vals[o.Long] = prev + "\n" + val
return return
} }
}
vals[o.Long] = val vals[o.Long] = val
} }
// appendList is the single home of the List newline-join rule, shared by the
// raw-parse layer (accumulate) and the resolved layer (set). An empty prev (the
// option not yet seen) yields val unchanged, so the first value carries no
// leading newline.
func appendList(prev, val string) string {
if prev == "" {
return val
}
return prev + "\n" + val
}
func applyDefaults(o *Options) { func applyDefaults(o *Options) {
for i := range options { for i := range options {
opt := &options[i] opt := &options[i]
@@ -224,8 +215,10 @@ func set(o *Options, name, val string) error {
if err := validate(opt, val); err != nil { if err := validate(opt, val); err != nil {
return err return err
} }
if opt.Kind == List && o.set[name] { if opt.Kind == List {
val = appendList(o.vals[name], val) if prev, ok := o.vals[name]; ok && o.set[name] {
val = prev + "\n" + val
}
} }
o.vals[name] = val o.vals[name] = val
o.set[name] = true o.set[name] = true
@@ -311,3 +304,8 @@ func confPath(explicit string) (path string, fromUser bool) {
} }
return filepath.Join(home, ".config", "got", "got.conf"), false return filepath.Join(home, ".config", "got", "got.conf"), false
} }
func truthy(s string) bool {
val, _ := boolWord(s)
return val
}

View File

@@ -22,7 +22,7 @@ func TestParseArgs(t *testing.T) {
{"permute flag after uri", []string{"u", "--split=8"}, Run, map[string]string{"split": "8"}, []string{"u"}}, {"permute flag after uri", []string{"u", "--split=8"}, Run, map[string]string{"split": "8"}, []string{"u"}},
{"permute interleaved", []string{"a", "-c", "b", "--quiet"}, Run, map[string]string{"continue": "true", "quiet": "true"}, []string{"a", "b"}}, {"permute interleaved", []string{"a", "-c", "b", "--quiet"}, Run, map[string]string{"continue": "true", "quiet": "true"}, []string{"a", "b"}},
{"dash dash terminator", []string{"--split=8", "--", "--not-a-flag", "u"}, Run, map[string]string{"split": "8"}, []string{"--not-a-flag", "u"}}, {"dash dash terminator", []string{"--split=8", "--", "--not-a-flag", "u"}, Run, map[string]string{"split": "8"}, []string{"--not-a-flag", "u"}},
{"long int + magnet uri", []string{"--bt-stop-timeout=120", "magnet:x"}, Run, map[string]string{"bt-stop-timeout": "120"}, []string{"magnet:x"}}, {"bt metadata timeout", []string{"--bt-metadata-timeout=120", "magnet:x"}, Run, map[string]string{"bt-metadata-timeout": "120"}, []string{"magnet:x"}},
{"help", []string{"--help"}, ShowHelp, nil, nil}, {"help", []string{"--help"}, ShowHelp, nil, nil},
{"help short", []string{"-h"}, ShowHelp, nil, nil}, {"help short", []string{"-h"}, ShowHelp, nil, nil},
{"version", []string{"-v"}, ShowVersion, nil, nil}, {"version", []string{"-v"}, ShowVersion, nil, nil},
@@ -68,6 +68,21 @@ func TestParseArgsErrors(t *testing.T) {
} }
} }
// got bounds the magnet metadata fetch by default rather than waiting forever;
// guard the default value and its zero floor.
func TestBTMetadataTimeoutDefault(t *testing.T) {
o, ok := byLong["bt-metadata-timeout"]
if !ok {
t.Fatal("bt-metadata-timeout option is missing")
}
if o.Default != "60" {
t.Errorf("bt-metadata-timeout default = %q, want %q", o.Default, "60")
}
if o.Min != 0 {
t.Errorf("bt-metadata-timeout Min = %d, want 0 (reject negatives, allow 0=off)", o.Min)
}
}
func TestParseSize(t *testing.T) { func TestParseSize(t *testing.T) {
tests := []struct { tests := []struct {
in string in string
@@ -139,19 +154,13 @@ func TestValidateFloatNonNegative(t *testing.T) {
func TestValidateBool(t *testing.T) { func TestValidateBool(t *testing.T) {
b := byLong["enable-dht"] b := byLong["enable-dht"]
// Only literal true/false are accepted (case-sensitive), with surrounding for _, ok := range []string{"true", "false", "yes", "no", "1", "0", "on", "off"} {
// whitespace trimmed.
for _, ok := range []string{"true", "false", " true ", "false\t"} {
if err := validate(b, ok); err != nil { if err := validate(b, ok); err != nil {
t.Errorf("validate bool %q: %v", ok, err) t.Errorf("validate bool %q: %v", ok, err)
} }
} }
// The invented spellings and any case variant must now fail. if err := validate(b, "flase"); err == nil {
for _, bad := range []string{"yes", "no", "1", "0", "on", "off", "flase", t.Errorf("validate bool flase: want error")
"True", "TRUE", "tRuE", "False", "FALSE"} {
if err := validate(b, bad); err == nil {
t.Errorf("validate bool %q: want error (only true/false accepted)", bad)
}
} }
} }

View File

@@ -55,6 +55,9 @@ type Stat struct {
Seeders int // connected seeders (BitTorrent only) Seeders int // connected seeders (BitTorrent only)
} }
// Done reports whether the download has reached a terminal state.
func (s Stat) Done() bool { return s.Status == Complete || s.Status == Errored }
// A Download is one logical job: a URL, a torrent, a magnet. Run blocks in its // A Download is one logical job: a URL, a torrent, a magnet. Run blocks in its
// own goroutine until the work finishes, fails, or ctx is cancelled. Stat may // own goroutine until the work finishes, fails, or ctx is cancelled. Stat may
// be called concurrently at any time and must not block; implementations back // be called concurrently at any time and must not block; implementations back

2
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25
require ( require (
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb
github.com/anacrolix/missinggo/v2 v2.10.0
github.com/anacrolix/torrent v1.61.0 github.com/anacrolix/torrent v1.61.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0 golang.org/x/term v0.37.0
@@ -21,7 +22,6 @@ require (
github.com/anacrolix/go-libutp v1.3.2 // indirect github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/anacrolix/mmsg v1.0.1 // indirect github.com/anacrolix/mmsg v1.0.1 // indirect
github.com/anacrolix/multiless v0.4.0 // indirect github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect github.com/anacrolix/stm v0.5.0 // indirect

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"sync/atomic" "sync/atomic"
) )
@@ -33,7 +34,7 @@ func controlPath(out string) string { return out + ".got" }
func snapshot(url string, total int64, etag, lastmod string, segs []seg) control { func snapshot(url string, total int64, etag, lastmod string, segs []seg) control {
c := control{URL: url, Total: total, ETag: etag, LastModified: lastmod, Segs: make([]segState, len(segs))} c := control{URL: url, Total: total, ETag: etag, LastModified: lastmod, Segs: make([]segState, len(segs))}
for i := range segs { for i := range segs {
c.Segs[i] = segState{segs[i].start, segs[i].end, atomic.LoadInt64(&segs[i].written)} c.Segs[i] = segState{segs[i].start, segs[i].endOff(), atomic.LoadInt64(&segs[i].written)}
} }
return c return c
} }
@@ -87,15 +88,16 @@ func loadControl(out, url string, total int64, etag, lastmod string) *control {
} }
// validSegs reports whether segs cover [0,total) with no gap or overlap and a // validSegs reports whether segs cover [0,total) with no gap or overlap and a
// sane written count for each. got always writes segments in ascending start // sane written count for each. The recorded order is not sorted (a steal appends
// order, so coverage is checked in place; an out-of-order sidecar (only possible // a tail), so we check coverage on a sorted copy.
// from a hand-edited or corrupted file) is rejected, which restarts cleanly.
func validSegs(segs []segState, total int64) bool { func validSegs(segs []segState, total int64) bool {
if total <= 0 || len(segs) == 0 { if total <= 0 || len(segs) == 0 {
return false return false
} }
sorted := append([]segState(nil), segs...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Start < sorted[j].Start })
var next int64 var next int64
for _, s := range segs { for _, s := range sorted {
length := s.End - s.Start + 1 length := s.End - s.Start + 1
if s.Start != next || s.End < s.Start || s.Written < 0 || s.Written > length { if s.Start != next || s.End < s.Start || s.Written < 0 || s.Written > length {
return false return false

View File

@@ -758,7 +758,7 @@ func (d *Download) fetchSeg(ctx context.Context, f *os.File, s *seg, total int64
func (d *Download) fetchOnce(ctx context.Context, f *os.File, s *seg, uri string, total int64) error { func (d *Download) fetchOnce(ctx context.Context, f *os.File, s *seg, uri string, total int64) error {
reqCtx, cancel := context.WithCancel(ctx) reqCtx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
req, err := d.request(reqCtx, uri, fmt.Sprintf("bytes=%d-%d", s.offset(), s.end)) req, err := d.request(reqCtx, uri, fmt.Sprintf("bytes=%d-%d", s.offset(), s.endOff()))
if err != nil { if err != nil {
return err return err
} }

View File

@@ -16,6 +16,7 @@ type seg struct {
} }
func (s *seg) length() int64 { return s.end - s.start + 1 } func (s *seg) length() int64 { return s.end - s.start + 1 }
func (s *seg) endOff() int64 { return s.end }
func (s *seg) done() bool { return atomic.LoadInt64(&s.written) >= s.length() } func (s *seg) done() bool { return atomic.LoadInt64(&s.written) >= s.length() }
func (s *seg) addWritten(n int64) { atomic.AddInt64(&s.written, n) } func (s *seg) addWritten(n int64) { atomic.AddInt64(&s.written, n) }
func (s *seg) progress() int64 { return atomic.LoadInt64(&s.written) } func (s *seg) progress() int64 { return atomic.LoadInt64(&s.written) }

View File

@@ -201,7 +201,7 @@ func tilesCover(t *testing.T, segs []seg, total int64) {
if s.start != next { if s.start != next {
t.Fatalf("segment gap/overlap: next byte %d, got start %d", next, s.start) t.Fatalf("segment gap/overlap: next byte %d, got start %d", next, s.start)
} }
next = s.end + 1 next = s.endOff() + 1
} }
if next != total { if next != total {
t.Fatalf("segments cover %d bytes, want %d", next, total) t.Fatalf("segments cover %d bytes, want %d", next, total)

54
main.go
View File

@@ -17,7 +17,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync"
"syscall" "syscall"
"time" "time"
@@ -94,12 +94,8 @@ func run(args []string) int {
t := time.AfterFunc(time.Duration(stop)*time.Second, cancel) t := time.AfterFunc(time.Duration(stop)*time.Second, cancel)
defer t.Stop() defer t.Stop()
} }
var jobsRef atomic.Pointer[[]job] jobsRef := &jobsHolder{}
// publish snapshots the slice header (j is a by-value copy) and stores a go handleSignals(cancel, sessionFile, jobsRef)
// pointer to it, so the signal handler can read the current jobs lock-free
// without racing the `jobs = append(...)` reassignment in the follow pass.
publish := func(j []job) { jobsRef.Store(&j) }
go handleSignals(cancel, sessionFile, &jobsRef)
eng := download.NewEngine(opts.Int("max-concurrent-downloads")) eng := download.NewEngine(opts.Int("max-concurrent-downloads"))
@@ -115,7 +111,7 @@ func run(args []string) int {
close(uiDone) close(uiDone)
} }
publish(jobs) jobsRef.set(jobs)
results := runJobs(ctx, eng, jobs) results := runJobs(ctx, eng, jobs)
// --follow-torrent: a .torrent fetched over HTTP becomes a BitTorrent // --follow-torrent: a .torrent fetched over HTTP becomes a BitTorrent
@@ -129,7 +125,7 @@ func run(args []string) int {
// Index space, so the sort below restores input order deterministically. // Index space, so the sort below restores input order deterministically.
nInitial := len(jobs) nInitial := len(jobs)
jobs = append(jobs, follow...) jobs = append(jobs, follow...)
publish(jobs) jobsRef.set(jobs)
followResults := runJobs(ctx, eng, follow) followResults := runJobs(ctx, eng, follow)
for i := range followResults { for i := range followResults {
followResults[i].Index += nInitial followResults[i].Index += nInitial
@@ -157,6 +153,25 @@ func run(args []string) int {
return report(results) return report(results)
} }
// jobsHolder lets the signal handler reach the latest job list for a
// best-effort session save on a forced (second-Ctrl-C) exit.
type jobsHolder struct {
mu sync.Mutex
jobs []job
}
func (h *jobsHolder) set(jobs []job) {
h.mu.Lock()
h.jobs = jobs
h.mu.Unlock()
}
func (h *jobsHolder) get() []job {
h.mu.Lock()
defer h.mu.Unlock()
return h.jobs
}
// job pairs a download with the source string that produced it, so we can write // job pairs a download with the source string that produced it, so we can write
// the still-unfinished ones back to a session file. // the still-unfinished ones back to a session file.
type job struct { type job struct {
@@ -509,21 +524,11 @@ func httpConfig(opts *cli.Options, single bool, overallDL *rate.Limiter) httpdl.
if single { if single {
out = opts.Str("out") out = opts.Str("out")
} }
// max-connection-per-server caps connections per host. Its default is 1,
// which on its own would clamp --split to a single connection, so a bare
// `got URL` would download single-threaded. Honor the literal default of 1
// only when the user actually set -x; otherwise let --split drive per-host
// parallelism, capped at the -x ceiling of 16. -x explicitly set keeps the
// exact min(split, mirrors*x) behavior.
perHost := opts.Int("max-connection-per-server")
if !opts.IsSet("max-connection-per-server") {
perHost = min(opts.Int("split"), 16)
}
return httpdl.Config{ return httpdl.Config{
Dir: opts.Str("dir"), Dir: opts.Str("dir"),
Out: out, Out: out,
Split: opts.Int("split"), Split: opts.Int("split"),
MaxConnPerServer: perHost, MaxConnPerServer: opts.Int("max-connection-per-server"),
MinSplit: opts.Size("min-split-size"), MinSplit: opts.Size("min-split-size"),
Tries: opts.Int("max-tries"), Tries: opts.Int("max-tries"),
Timeout: time.Duration(opts.Int("timeout")) * time.Second, Timeout: time.Duration(opts.Int("timeout")) * time.Second,
@@ -578,6 +583,7 @@ func btOptions(opts *cli.Options) bt.Options {
SeedTime: time.Duration(opts.Float("seed-time") * float64(time.Minute)), SeedTime: time.Duration(opts.Float("seed-time") * float64(time.Minute)),
SeedRatio: opts.Float("seed-ratio"), SeedRatio: opts.Float("seed-ratio"),
StopTimeout: time.Duration(opts.Int("bt-stop-timeout")) * time.Second, StopTimeout: time.Duration(opts.Int("bt-stop-timeout")) * time.Second,
MetaTimeout: time.Duration(opts.Int("bt-metadata-timeout")) * time.Second,
SelectFiles: parseSelect(opts.Str("select-file")), SelectFiles: parseSelect(opts.Str("select-file")),
CheckIntegrity: opts.Bool("check-integrity"), CheckIntegrity: opts.Bool("check-integrity"),
DryRun: opts.Bool("dry-run"), DryRun: opts.Bool("dry-run"),
@@ -795,18 +801,14 @@ func progressNote(s download.Stat) string {
// TERM) cancels the graceful context so run() can wind down and save normally; // TERM) cancels the graceful context so run() can wind down and save normally;
// a second forces exit, but first writes the session best-effort so the resume // a second forces exit, but first writes the session best-effort so the resume
// list survives a force-quit. // list survives a force-quit.
func handleSignals(cancel context.CancelFunc, sessionFile string, jobs *atomic.Pointer[[]job]) { func handleSignals(cancel context.CancelFunc, sessionFile string, jobs *jobsHolder) {
sig := make(chan os.Signal, 2) sig := make(chan os.Signal, 2)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM) signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig // first: cancel the graceful context <-sig // first: cancel the graceful context
cancel() cancel()
<-sig // second: force exit <-sig // second: force exit
if sessionFile != "" { if sessionFile != "" {
var js []job // nil until the first publish; saveSession handles an empty list if err := saveSession(sessionFile, jobs.get()); err != nil {
if p := jobs.Load(); p != nil {
js = *p
}
if err := saveSession(sessionFile, js); err != nil {
fmt.Fprintln(os.Stderr, "got:", err) fmt.Fprintln(os.Stderr, "got:", err)
} }
} }

View File

@@ -144,36 +144,6 @@ func TestGatherURIsGrouping(t *testing.T) {
} }
} }
// TestHTTPConnDefault locks the parallelism default: a bare `got URL` must use
// several connections, not one. With -x unset, --split drives the per-host
// connection count (capped at the -x ceiling of 16); an explicit -x is honored
// verbatim, including -x1 for a single connection.
func TestHTTPConnDefault(t *testing.T) {
cfg := func(args ...string) httpdl.Config {
res, err := cli.Parse(append([]string{"--no-conf"}, args...))
if err != nil {
t.Fatalf("parse %v: %v", args, err)
}
return httpConfig(res.Options, true, nil)
}
tests := []struct {
name string
args []string
want int
}{
{"bare URL uses split default", []string{"http://a/x"}, 5},
{"-s16 means 16", []string{"-s16", "http://a/x"}, 16},
{"-s100 capped at 16 per host", []string{"-s100", "http://a/x"}, 16},
{"explicit -x4 honored", []string{"-x4", "http://a/x"}, 4},
{"explicit -x1 stays single", []string{"-x1", "http://a/x"}, 1},
}
for _, tc := range tests {
if c := cfg(tc.args...); c.MaxConnPerServer != tc.want {
t.Errorf("%s: MaxConnPerServer = %d, want %d", tc.name, c.MaxConnPerServer, tc.want)
}
}
}
// TestReadInputFileTAB checks the input-file grouping: TAB-separated URIs on // TestReadInputFileTAB checks the input-file grouping: TAB-separated URIs on
// a line are mirrors of one download; a plain line is one download. // a line are mirrors of one download; a plain line is one download.
func TestReadInputFileTAB(t *testing.T) { func TestReadInputFileTAB(t *testing.T) {

View File

@@ -41,6 +41,12 @@ func humanSize(n int64, human bool) string {
return fmt.Sprintf("%.0f%ciB", val, suffix) return fmt.Sprintf("%.0f%ciB", val, suffix)
} }
// speed formats a bytes-per-second rate. The DL:/UL: fields show the bare
// abbreviated size with no "/s" suffix.
func speed(bytesPerSec int64, human bool) string {
return humanSize(bytesPerSec, human)
}
// secfmt renders a duration as "1h2m3s", appending each unit only when it is // secfmt renders a duration as "1h2m3s", appending each unit only when it is
// nonzero (but still showing seconds when the whole input is 0): // nonzero (but still showing seconds when the whole input is 0):
// 3600s->"1h", 120s->"2m", 3720s->"1h2m". A non-positive or absurd // 3600s->"1h", 120s->"2m", 3720s->"1h2m". A non-positive or absurd

View File

@@ -53,6 +53,16 @@ func TestSecfmt(t *testing.T) {
} }
} }
func TestSpeedNoSuffix(t *testing.T) {
// The DL:/UL: fields show the bare abbreviated size with no "/s".
if got := speed(1536, true); got != "1.5KiB" {
t.Errorf("speed(1536,true) = %q, want %q", got, "1.5KiB")
}
if got := speed(2048, false); got != "2048B" {
t.Errorf("speed(2048,false) = %q, want %q", got, "2048B")
}
}
func TestPercent(t *testing.T) { func TestPercent(t *testing.T) {
if got := percent(50, 100); got != 50 { if got := percent(50, 100); got != 50 {
t.Errorf("percent(50,100) = %d, want 50", got) t.Errorf("percent(50,100) = %d, want 50", got)

View File

@@ -147,10 +147,10 @@ func (r *Reporter) lineOne(s download.Stat) string {
fmt.Fprintf(&b, " SD:%d", s.Seeders) fmt.Fprintf(&b, " SD:%d", s.Seeders)
} }
if !seeding { if !seeding {
fmt.Fprintf(&b, " DL:%s", humanSize(dl, r.human)) fmt.Fprintf(&b, " DL:%s", speed(dl, r.human))
} }
if seeding || ul > 0 { if seeding || ul > 0 {
fmt.Fprintf(&b, " UL:%s", humanSize(ul, r.human)) fmt.Fprintf(&b, " UL:%s", speed(ul, r.human))
} }
if !seeding && s.Total > 0 && dl > 0 { if !seeding && s.Total > 0 && dl > 0 {
fmt.Fprintf(&b, " ETA:%s", secfmt(etaDuration(s.Total-s.Completed, dl))) fmt.Fprintf(&b, " ETA:%s", secfmt(etaDuration(s.Total-s.Completed, dl)))
@@ -227,7 +227,7 @@ func (r *Reporter) summary(stats []download.Stat) string {
stalled++ stalled++
} }
} }
line := fmt.Sprintf("%d active DL:%s UL:%s", len(stats)-waiting, humanSize(totDL, r.human), humanSize(totUL, r.human)) line := fmt.Sprintf("%d active DL:%s UL:%s", len(stats)-waiting, speed(totDL, r.human), speed(totUL, r.human))
if waiting > 0 { if waiting > 0 {
line += fmt.Sprintf(" (%d waiting)", waiting) line += fmt.Sprintf(" (%d waiting)", waiting)
} }
@@ -254,13 +254,13 @@ func (r *Reporter) rowMetric(s download.Stat, dl, ul int64) (metric, tail string
case download.Waiting: case download.Waiting:
return "queued", "" return "queued", ""
case download.Seeding: case download.Seeding:
return "seeding", "UL:" + humanSize(ul, r.human) return "seeding", "UL:" + speed(ul, r.human)
case download.Complete: case download.Complete:
return "done", "" return "done", ""
case download.Errored: case download.Errored:
return "failed", "" return "failed", ""
default: default:
metric = "DL:" + humanSize(dl, r.human) metric = "DL:" + speed(dl, r.human)
switch { switch {
case dl > 0 && s.Total > 0: case dl > 0 && s.Total > 0:
tail = secfmt(etaDuration(s.Total-s.Completed, dl)) tail = secfmt(etaDuration(s.Total-s.Completed, dl))