Files
zzg 448681a3a9
ci / test (push) Canceled after 0s
initial commit
2026-09-25 22:21:10 -07:00

460 lines
10 KiB
Go

// Package download coordinates fetching a torrent's pieces from peers and
// writing them to disk.
package download
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/zg/btcli/metainfo"
"github.com/zg/btcli/peer"
)
const (
// BlockSize is the block length every client uses. Pieces are
// requested in blocks because a piece can be megabytes.
BlockSize = 16 * 1024
// MaxBacklog is how many requests are kept in flight per peer.
// Pipelining is what makes throughput acceptable: one request at a
// time means paying a full round trip per 16 KiB.
MaxBacklog = 5
)
// PieceWork is a piece to fetch.
type PieceWork struct {
Index int
Hash metainfo.Hash
Length int
}
// PieceResult is a completed, hash-verified piece.
type PieceResult struct {
Index int
Data []byte
}
// Progress reports download state to a caller.
type Progress struct {
Downloaded int64
Total int64
Pieces int
TotalPieces int
Peers int
}
// Percent returns the completion percentage.
func (p Progress) Percent() float64 {
if p.Total == 0 {
return 0
}
return float64(p.Downloaded) / float64(p.Total) * 100
}
// Torrent is a download in progress.
type Torrent struct {
Meta *metainfo.Metainfo
PeerID [20]byte
Dir string
// Timeouts for peer operations.
PeerTimeout time.Duration
downloaded atomic.Int64
donePieces atomic.Int64
numPeers atomic.Int64
mu sync.Mutex
onPiece func(Progress)
}
// New returns a torrent ready to download into dir.
func New(meta *metainfo.Metainfo, dir string) (*Torrent, error) {
peerID, err := GeneratePeerID()
if err != nil {
return nil, err
}
return &Torrent{
Meta: meta,
PeerID: peerID,
Dir: dir,
PeerTimeout: 15 * time.Second,
}, nil
}
// GeneratePeerID returns a random peer ID with the conventional
// "-XX0000-" client prefix.
func GeneratePeerID() ([20]byte, error) {
var id [20]byte
copy(id[:], "-ZG0001-")
if _, err := rand.Read(id[8:]); err != nil {
return id, fmt.Errorf("download: generating peer id: %w", err)
}
return id, nil
}
// OnProgress registers a progress callback.
func (t *Torrent) OnProgress(fn func(Progress)) {
t.mu.Lock()
t.onPiece = fn
t.mu.Unlock()
}
func (t *Torrent) report() {
t.mu.Lock()
fn := t.onPiece
t.mu.Unlock()
if fn == nil {
return
}
fn(Progress{
Downloaded: t.downloaded.Load(),
Total: t.Meta.Info.TotalLength(),
Pieces: int(t.donePieces.Load()),
TotalPieces: t.Meta.Info.NumPieces(),
Peers: int(t.numPeers.Load()),
})
}
// Download fetches every piece from the given peers and writes the result
// to disk.
func (t *Torrent) Download(ctx context.Context, peers []peer.Peer) error {
if len(peers) == 0 {
return errors.New("download: no peers available")
}
info := &t.Meta.Info
work := make(chan *PieceWork, info.NumPieces())
results := make(chan *PieceResult, info.NumPieces())
for i, hash := range info.Pieces {
work <- &PieceWork{Index: i, Hash: hash, Length: int(info.PieceSize(i))}
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
for _, p := range peers {
wg.Add(1)
go func(p peer.Peer) {
defer wg.Done()
t.serve(ctx, p, work, results)
}(p)
}
// Close results once every peer worker has stopped, so the collector
// below terminates even if the swarm cannot supply every piece.
go func() {
wg.Wait()
close(results)
}()
writer, err := newWriter(t.Dir, info)
if err != nil {
return err
}
defer writer.Close()
collected := 0
for collected < info.NumPieces() {
select {
case <-ctx.Done():
return ctx.Err()
case res, ok := <-results:
if !ok {
return fmt.Errorf("download: peers exhausted with %d of %d pieces",
collected, info.NumPieces())
}
offset := int64(res.Index) * info.PieceLength
if err := writer.WriteAt(res.Data, offset); err != nil {
return err
}
collected++
t.donePieces.Add(1)
t.downloaded.Add(int64(len(res.Data)))
t.report()
}
}
cancel()
return nil
}
// serve runs one peer: connect, then repeatedly take work and fetch it.
func (t *Torrent) serve(ctx context.Context, p peer.Peer, work chan *PieceWork, results chan<- *PieceResult) {
c, err := peer.Dial(ctx, p, t.Meta.InfoHash, t.PeerID, t.Meta.Info.NumPieces(), t.PeerTimeout)
if err != nil {
return
}
defer c.Close()
t.numPeers.Add(1)
defer t.numPeers.Add(-1)
c.SendUnchoke()
c.SendInterested()
for {
select {
case <-ctx.Done():
return
case pw, ok := <-work:
if !ok {
return
}
// A peer that does not have this piece cannot serve it; put it
// back for someone else rather than failing.
if !c.Bitfield.HasPiece(pw.Index) {
select {
case work <- pw:
case <-ctx.Done():
}
continue
}
data, err := t.fetchPiece(ctx, c, pw)
if err != nil {
// Return the work so another peer can try it. This peer is
// finished: an error here usually means the connection is
// unusable.
select {
case work <- pw:
case <-ctx.Done():
}
return
}
// Verify before accepting. A peer can send anything, and an
// unverified piece would silently corrupt the file.
if !t.Meta.Info.VerifyPiece(pw.Index, data) {
select {
case work <- pw:
case <-ctx.Done():
}
continue
}
c.SendHave(pw.Index)
select {
case results <- &PieceResult{Index: pw.Index, Data: data}:
case <-ctx.Done():
return
}
}
}
}
// fetchPiece downloads one piece, pipelining block requests.
func (t *Torrent) fetchPiece(ctx context.Context, c *peer.Conn, pw *PieceWork) ([]byte, error) {
buf := make([]byte, pw.Length)
downloaded, requested, backlog := 0, 0, 0
// A deadline bounds the whole piece: a peer that stops responding
// mid-piece must not hold the slot forever.
c.SetDeadline(time.Now().Add(t.PeerTimeout * 4))
defer c.SetDeadline(time.Time{})
for downloaded < pw.Length {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Only request while unchoked; a choked peer discards requests.
if !c.Choked {
for backlog < MaxBacklog && requested < pw.Length {
length := BlockSize
// The final block of the final piece is short.
if pw.Length-requested < length {
length = pw.Length - requested
}
if err := c.SendRequest(pw.Index, requested, length); err != nil {
return nil, err
}
backlog++
requested += length
}
}
msg, err := c.Read()
if err != nil {
return nil, err
}
if c.HandleState(msg) {
continue // keep-alive, choke, unchoke or have
}
if msg.ID == peer.MsgPiece {
n, err := peer.ParsePiece(pw.Index, buf, msg)
if err != nil {
return nil, err
}
downloaded += n
backlog--
}
}
return buf, nil
}
// writer maps piece offsets onto the torrent's files. A multi-file torrent
// is one contiguous byte stream split across files at arbitrary offsets, so
// a single write can span two of them.
type writer struct {
files []*os.File
spans []span
single bool
}
type span struct {
file *os.File
begin, end int64 // offsets within the overall stream
}
func newWriter(dir string, info *metainfo.Info) (*writer, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
w := &writer{}
if info.IsSingleFile() {
path := filepath.Join(dir, info.Name)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644)
if err != nil {
return nil, err
}
w.files = append(w.files, f)
w.spans = append(w.spans, span{file: f, begin: 0, end: info.Length})
w.single = true
return w, nil
}
root := filepath.Join(dir, info.Name)
var offset int64
for _, mf := range info.Files {
path, err := mf.FullPath(root)
if err != nil {
w.Close()
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
w.Close()
return nil, err
}
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644)
if err != nil {
w.Close()
return nil, err
}
w.files = append(w.files, f)
w.spans = append(w.spans, span{file: f, begin: offset, end: offset + mf.Length})
offset += mf.Length
}
return w, nil
}
// WriteAt writes data at a stream offset, splitting it across files where
// the write crosses a file boundary.
func (w *writer) WriteAt(data []byte, offset int64) error {
for _, s := range w.spans {
if offset >= s.end || offset+int64(len(data)) <= s.begin {
continue
}
// The portion of data that lands in this file.
from := max64(offset, s.begin)
to := min64(offset+int64(len(data)), s.end)
chunk := data[from-offset : to-offset]
if _, err := s.file.WriteAt(chunk, from-s.begin); err != nil {
return err
}
}
return nil
}
func (w *writer) Close() error {
var firstErr error
for _, f := range w.files {
if err := f.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
// Verify checks already-downloaded data on disk, returning a bitfield of
// the pieces that are present and correct. This is what makes a download
// resumable.
func Verify(dir string, info *metainfo.Info) (peer.Bitfield, error) {
have := peer.NewBitfield(info.NumPieces())
r, err := newReader(dir, info)
if err != nil {
return have, err
}
defer r.Close()
buf := make([]byte, info.PieceLength)
for i := range info.NumPieces() {
size := info.PieceSize(i)
n, err := r.ReadAt(buf[:size], int64(i)*info.PieceLength)
if err != nil && !errors.Is(err, io.EOF) {
continue
}
if int64(n) != size {
continue
}
if info.VerifyPiece(i, buf[:size]) {
have.SetPiece(i)
}
}
return have, nil
}
type reader struct{ w *writer }
func newReader(dir string, info *metainfo.Info) (*reader, error) {
w, err := newWriter(dir, info)
if err != nil {
return nil, err
}
return &reader{w: w}, nil
}
func (r *reader) ReadAt(buf []byte, offset int64) (int, error) {
total := 0
for _, s := range r.w.spans {
if offset >= s.end || offset+int64(len(buf)) <= s.begin {
continue
}
from := max64(offset, s.begin)
to := min64(offset+int64(len(buf)), s.end)
n, err := s.file.ReadAt(buf[from-offset:to-offset], from-s.begin)
total += n
if err != nil && !errors.Is(err, io.EOF) {
return total, err
}
}
return total, nil
}
func (r *reader) Close() error { return r.w.Close() }