httpdl/cli: drop --auto-split; classify errors by type, not message text

Two Pike cleanups.

--auto-split was a third connection-count policy (beside -x and -s) that added
an extra knob rather than keeping the model minimal. 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.
This commit is contained in:
2026-06-20 23:55:39 +09:00
parent ebc630b231
commit 508708b039
8 changed files with 27 additions and 108 deletions

View File

@@ -57,8 +57,7 @@ type Config struct {
Split int // --split: total connections across all mirrors
MaxConnPerServer int // --max-connection-per-server: per-host connection cap
MinSplit int64
AutoSplit bool // --auto-split (got-only): pick the connection count from file size
Tries int // 0 = unlimited
Tries int // 0 = unlimited
Timeout time.Duration
FileAlloc string // none | prealloc | trunc | falloc
Continue bool
@@ -120,7 +119,7 @@ type Config struct {
// connections.
type Download struct {
uris []string // mirror list; uris[0] is the primary (naming + resume key)
maxConns int // manual segment-worker cap = min(split, len(uris)*max-connection-per-server); --auto-split derives its own count per file in segmented()
maxConns int // segment-worker cap = min(split, len(uris)*max-connection-per-server)
cfg Config
client *http.Client
limit []*rate.Limiter
@@ -216,17 +215,10 @@ func New(uris []string, cfg Config) *Download {
if perHost < 1 {
perHost = 1
}
// The transport must allow as many concurrent connections per host as we may
// actually open. --auto-split scales up to maxAutoConns per host once the file
// size is known (in segmented), so raise the cap to that ceiling when it is on.
connCap := perHost
if cfg.AutoSplit {
connCap = maxAutoConns
}
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxConnsPerHost: connCap,
MaxIdleConnsPerHost: connCap,
MaxConnsPerHost: perHost,
MaxIdleConnsPerHost: perHost,
ResponseHeaderTimeout: cfg.Timeout,
TLSHandshakeTimeout: connectTimeout,
DialContext: dialContext,
@@ -671,13 +663,7 @@ func (d *Download) probe(ctx context.Context, uri string) (probeResult, error) {
// segmented downloads total bytes over d.maxConns workers, with per-segment
// resume from the control file.
func (d *Download) segmented(ctx context.Context, out string, total int64, etag, lastmod string) error {
// --auto-split picks the connection count from the file size now that total is
// known; otherwise use the manual cap fixed at construction.
conns := d.maxConns
if d.cfg.AutoSplit {
conns = autoConns(total, d.cfg.MinSplit)
}
segs := makeSegments(total, d.cfg.MinSplit, conns)
segs := makeSegments(total, d.cfg.MinSplit, d.maxConns)
if c := d.resumeControl(out, etag, lastmod); c != nil {
segs = segsFromControl(c)
} else if d.cfg.Continue && !fileExists(controlPath(out)) {
@@ -809,10 +795,11 @@ func (d *Download) fetchOnce(ctx context.Context, f *os.File, s *seg, uri string
return d.pump(ctx, f, s, body)
}
// errIdleTimeout marks a transfer that stalled past the idle window, so callers
// can report a distinct "timed out" instead of the bare "context canceled" that
// the cancelled request would otherwise surface (item [39]).
var errIdleTimeout = errors.New("idle timeout: no data received")
// ErrTimeout marks a transfer that stalled past the idle window (--timeout): a
// distinct "timed out" instead of the bare "context canceled" the cancelled
// request would otherwise surface (item [39]). main maps it to exit code 2 via
// errors.Is, so it must stay on the error chain (CONTRACT).
var ErrTimeout = errors.New("idle timeout: no data received")
// idleReader records the time of the last byte received; a background watcher
// (idleGuard) cancels the request once a full idle window passes with no
@@ -834,7 +821,7 @@ func (ir *idleReader) Read(p []byte) (int, error) {
if err != nil && ir.timedOut.Load() {
// The cancellation came from our watcher, not the caller's ctx, so
// translate the read error into the distinct idle-timeout sentinel.
return n, fmt.Errorf("%w (after %s)", errIdleTimeout, ir.idle)
return n, fmt.Errorf("%w (after %s)", ErrTimeout, ir.idle)
}
return n, err
}