Files
got/httpdl/segment_test.go
Hojun-Cho ebc630b231 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

321 lines
8.7 KiB
Go

package httpdl
import (
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"testing"
)
func TestMakeSegments(t *testing.T) {
tests := []struct {
name string
total int64
minSplit int64
conns int
wantN int
}{
{"even split", 1000, 100, 5, 5},
{"min-split caps count", 1000, 400, 5, 2},
{"tiny file is one segment", 50, 100, 5, 1},
{"single connection", 1000, 1, 1, 1},
{"min-split exact", 1000, 200, 5, 5},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
segs := makeSegments(tc.total, tc.minSplit, tc.conns)
if len(segs) != tc.wantN {
t.Fatalf("got %d segments, want %d", len(segs), tc.wantN)
}
// Segments must be contiguous and cover the whole file exactly.
var next int64
for i, s := range segs {
if s.start != next {
t.Errorf("segment %d starts at %d, want %d", i, s.start, next)
}
if s.end < s.start {
t.Errorf("segment %d has end %d < start %d", i, s.end, s.start)
}
next = s.end + 1
}
if next != tc.total {
t.Errorf("segments cover %d bytes, want %d", next, tc.total)
}
})
}
}
func TestAutoConns(t *testing.T) {
const m = int64(20 << 20) // 20 MiB min-split, aria2's default
tests := []struct {
name string
total int64
minSplit int64
want int
}{
{"under one min-split is a single connection", 5 << 20, m, 1},
{"exactly one min-split", m, m, 1},
{"five min-splits", 100 << 20, m, 5},
{"caps at maxAutoConns", 10 << 30, m, maxAutoConns},
{"rounds down to whole pieces", m*3 + 1, m, 3},
{"tiny min-split still capped", 1 << 30, 1 << 20, maxAutoConns},
{"zero total is one connection", 0, m, 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := autoConns(tc.total, tc.minSplit); got != tc.want {
t.Errorf("autoConns(%d, %d) = %d, want %d", tc.total, tc.minSplit, got, tc.want)
}
})
}
}
func TestSegProgress(t *testing.T) {
s := seg{start: 100, end: 199} // length 100
if s.length() != 100 {
t.Fatalf("length = %d", s.length())
}
if s.done() {
t.Fatalf("new segment should not be done")
}
s.addWritten(60)
if s.offset() != 160 {
t.Errorf("offset = %d, want 160", s.offset())
}
if s.remaining() != 40 {
t.Errorf("remaining = %d, want 40", s.remaining())
}
s.addWritten(40)
if !s.done() {
t.Errorf("segment should be done after writing full length")
}
}
func TestSplitExt(t *testing.T) {
tests := []struct {
in string
stem, ext string
}{
{"foo.txt", "foo", ".txt"},
{"foo", "foo", ""},
{"/dir/foo.tar.gz", "/dir/foo.tar", ".gz"},
{"/dir/.bashrc", "/dir/.bashrc", ""}, // dotfile, no extension
{".bashrc", ".bashrc", ""},
{"archive.", "archive", "."},
}
for _, tc := range tests {
stem, ext := splitExt(tc.in)
if stem != tc.stem || ext != tc.ext {
t.Errorf("splitExt(%q) = %q,%q; want %q,%q", tc.in, stem, ext, tc.stem, tc.ext)
}
}
}
func TestUniqueName(t *testing.T) {
dir := t.TempDir()
// foo.txt taken -> foo.1.txt (.N inserted before the extension, item [13]).
taken := filepath.Join(dir, "foo.txt")
if err := os.WriteFile(taken, nil, 0o644); err != nil {
t.Fatal(err)
}
got, err := uniqueName(taken)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if want := filepath.Join(dir, "foo.1.txt"); got != want {
t.Errorf("uniqueName(%q) = %q, want %q", taken, got, want)
}
// No-extension base -> base.1.
plain := filepath.Join(dir, "data")
if err := os.WriteFile(plain, nil, 0o644); err != nil {
t.Fatal(err)
}
got, err = uniqueName(plain)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if want := filepath.Join(dir, "data.1"); got != want {
t.Errorf("uniqueName(%q) = %q, want %q", plain, got, want)
}
// foo.txt and foo.1.txt both taken -> foo.2.txt.
if err := os.WriteFile(filepath.Join(dir, "foo.1.txt"), nil, 0o644); err != nil {
t.Fatal(err)
}
got, err = uniqueName(taken)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if want := filepath.Join(dir, "foo.2.txt"); got != want {
t.Errorf("uniqueName second pass = %q, want %q", got, want)
}
// A candidate that exists but has a .got control file is acceptable (it is a
// resumable partial).
ctrlBase := filepath.Join(dir, "r.bin")
cand1 := filepath.Join(dir, "r.1.bin")
if err := os.WriteFile(ctrlBase, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cand1, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(controlPath(cand1), nil, 0o644); err != nil {
t.Fatal(err)
}
got, err = uniqueName(ctrlBase)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if got != cand1 {
t.Errorf("uniqueName with control sidecar = %q, want %q", got, cand1)
}
}
func TestNameFromContentDisposition(t *testing.T) {
tests := []struct {
cd, want string
}{
{`attachment; filename="report.pdf"`, "report.pdf"},
{`attachment; filename=report.pdf`, "report.pdf"},
{`inline; filename*=UTF-8''na%C3%AFve.txt`, "naïve.txt"},
{`attachment; filename="../../etc/passwd"`, "passwd"}, // path stripped
{`attachment`, ""},
{`garbage`, ""},
}
for _, tc := range tests {
if got := nameFromContentDisposition(tc.cd); got != tc.want {
t.Errorf("nameFromContentDisposition(%q) = %q, want %q", tc.cd, got, tc.want)
}
}
}
func TestSeedSegmentsFromPrefix(t *testing.T) {
segs := makeSegments(1000, 100, 5) // five 200-byte segments
seedSegmentsFromPrefix(segs, 450) // 2 full segments + 50 bytes of the third
wants := []int64{200, 200, 50, 0, 0}
for i := range segs {
if segs[i].written != wants[i] {
t.Errorf("segment %d written = %d, want %d", i, segs[i].written, wants[i])
}
}
}
func TestParseContentRange(t *testing.T) {
start, end, total, ok := parseContentRange("bytes 0-0/12345")
if !ok || start != 0 || end != 0 || total != 12345 {
t.Errorf("parseContentRange = %d,%d,%d,%v", start, end, total, ok)
}
if _, _, _, ok := parseContentRange("garbage"); ok {
t.Errorf("parseContentRange(garbage) ok = true, want false")
}
}
// tilesCover checks that segs cover [0,total) exactly: sorted by start they must
// be contiguous with no gap and no overlap.
func tilesCover(t *testing.T, segs []seg, total int64) {
t.Helper()
sorted := append([]seg(nil), segs...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].start < sorted[j].start })
var next int64
for _, s := range sorted {
if s.start != next {
t.Fatalf("segment gap/overlap: next byte %d, got start %d", next, s.start)
}
next = s.endOff() + 1
}
if next != total {
t.Fatalf("segments cover %d bytes, want %d", next, total)
}
}
// TestSegmentsConcurrentCoverage drives the segments the way real workers do —
// one worker per segment, each copying its range in small chunks and committing
// with addWritten. The coverage array proves the core invariant: every byte is
// written exactly once, with no gap or overlap between adjacent segments.
func TestSegmentsConcurrentCoverage(t *testing.T) {
const (
total = int64(64 * readBuf) // 2 MiB
minSplit = int64(readBuf)
chunk = int64(readBuf / 4) // one "read"
conns = 8
)
segs := makeSegments(total, minSplit, conns)
if len(segs) < 2 {
t.Fatalf("expected several segments, got %d", len(segs))
}
cover := make([]uint32, total)
var wg sync.WaitGroup
for i := range segs {
wg.Add(1)
go func(s *seg) {
defer wg.Done()
for s.remaining() > 0 {
n := chunk
if s.remaining() < n {
n = s.remaining()
}
off := s.offset()
for j := off; j < off+n; j++ {
atomic.AddUint32(&cover[j], 1)
}
s.addWritten(n)
}
}(&segs[i])
}
wg.Wait()
for i, c := range cover {
if c != 1 {
t.Fatalf("byte %d written %d times, want exactly 1", i, c)
}
}
tilesCover(t, segs, total)
}
// TestSnapshotRoundTrips checks the persistence path an interrupt-and-resume
// relies on: a snapshot of partially-written segments records each frontier, and
// segsFromControl rebuilds a set that still tiles the file and keeps the bytes
// already written.
func TestSnapshotRoundTrips(t *testing.T) {
const (
total = int64(8 * readBuf)
minSplit = int64(readBuf)
)
segs := makeSegments(total, minSplit, 4)
if len(segs) < 2 {
t.Fatalf("expected several segments, got %d", len(segs))
}
// Each segment makes some progress, so the snapshot has a real frontier to
// carry across the round trip.
var want int64
for i := range segs {
n := int64(readBuf)
if n > segs[i].length() {
n = segs[i].length()
}
segs[i].addWritten(n)
want += n
}
c := snapshot("http://x", total, "", "", segs)
if len(c.Segs) != len(segs) {
t.Fatalf("snapshot has %d segments, want %d", len(c.Segs), len(segs))
}
rebuilt := segsFromControl(&c)
tilesCover(t, rebuilt, total)
var written int64
for _, s := range rebuilt {
written += s.progress()
}
if written != want {
t.Errorf("rebuilt written = %d, want %d (bytes must survive the round trip)", written, want)
}
}