Files
gopeed/internal/protocol/http/fetcher.go
T
copilot-swe-agent[bot]andmonkeyWie b8e517b35b Fix data race in expandConnections: remove orphaned waitForCompletion goroutine
The go f.waitForCompletion() goroutine in expandConnections was redundant
since the downloadLoop's slow start loop already handles stateSteady by
calling waitForCompletion directly. The orphaned goroutine could race with
doStart() when writing f.ctx during pause/resume.

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 02:59:41 +00:00

1808 lines
45 KiB
Go

package http
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/xiaoqidun/setft"
)
const (
connectTimeout = 15 * time.Second
readTimeout = 15 * time.Second
minFastFailTimeout = int64(3 * time.Second) // Minimum timeout for fast-fail retry
// Work stealing parameters
// When a connection finishes its chunk, it can "steal" work from slow connections.
stealThresholdSeconds = 3 // Only steal if victim needs > 3 seconds to finish
stealMinChunkSize = 512 * 1024 // Min steal size: 512KB (avoid tiny chunks)
)
// ============================================================================
// State Machine
// ============================================================================
type fetcherState int32
const (
stateIdle fetcherState = iota // Initial state
stateResolving // Resolving resource info
stateResolved // Resolved, waiting for Start or downloading
stateSlowStart // Slow-start phase: exponential connection growth
stateSteady // Steady state: max connections reached
statePaused // Paused
stateDone // Completed
stateError // Error occurred
)
// ============================================================================
// Connection
// ============================================================================
type connectionState int32
const (
connNotStarted connectionState = iota // Not yet started
connConnecting // Sending HTTP request
connDownloading // HTTP response OK, downloading
connCompleted // Completed
connFailed // Failed
)
type connectionRole int
const (
roleResolve connectionRole = iota // Resolve connection: initial probe + temp download
rolePrimary // Primary connection: first successful takeover from Resolve
roleWorker // Worker connection: subsequent connections
)
type chunk struct {
Begin int64
End int64
Downloaded int64
}
func (c *chunk) remain() int64 {
return c.End - c.Begin + 1 - c.Downloaded
}
func newChunk(begin int64, end int64) *chunk {
return &chunk{
Begin: begin,
End: end,
}
}
type connection struct {
ID int
Role connectionRole
State connectionState
Chunk *chunk
Downloaded int64
Completed bool
failed bool
retryTimes int
lastErr error
// Speed tracking for work stealing decisions
speed int64 // bytes per second
lastSpeedCheck int64 // timestamp in nanoseconds
lastSpeedDownload int64 // bytes downloaded at last check
ctx context.Context
cancel context.CancelFunc
}
// ============================================================================
// Slow Start Controller
// ============================================================================
type slowStartController struct {
mu sync.Mutex
maxConnections int
totalLaunched int
batchPending int // Connections in current batch waiting for HTTP response
batchReady int // Connections in current batch that succeeded
nextBatchSize int // Next batch size: 1, 2, 4, 8...
expansionCh chan struct{} // Signal to trigger next expansion
paused bool // Pause expansion (e.g., on 429)
}
func newSlowStartController(maxConnections int) *slowStartController {
return &slowStartController{
maxConnections: maxConnections,
nextBatchSize: 1,
expansionCh: make(chan struct{}, 1),
}
}
// onConnectSuccess is called when a connection successfully gets HTTP response
// Returns true if this completes the current batch
func (s *slowStartController) onConnectSuccess() bool {
s.mu.Lock()
defer s.mu.Unlock()
s.batchReady++
if s.batchReady >= s.batchPending {
// Batch complete, signal expansion
select {
case s.expansionCh <- struct{}{}:
default:
}
return true
}
return false
}
// onConnectFailed is called when a connection fails
func (s *slowStartController) onConnectFailed() {
s.mu.Lock()
defer s.mu.Unlock()
// Reduce pending count
if s.batchPending > 0 {
s.batchPending--
}
// If all pending resolved (success or fail), trigger expansion
// This handles both successful completion and all-failures case
if s.batchPending == 0 {
select {
case s.expansionCh <- struct{}{}:
default:
}
}
}
// getNextBatchSize returns how many connections to start in next batch
// Returns 0 if max reached
func (s *slowStartController) getNextBatchSize() int {
s.mu.Lock()
defer s.mu.Unlock()
if s.paused {
return 0
}
remaining := s.maxConnections - s.totalLaunched
if remaining <= 0 {
return 0
}
batchSize := s.nextBatchSize
if batchSize > remaining {
batchSize = remaining
}
return batchSize
}
// commitBatch confirms that a batch of connections is being launched
func (s *slowStartController) commitBatch(count int) {
s.mu.Lock()
defer s.mu.Unlock()
s.totalLaunched += count
s.nextBatchSize = s.nextBatchSize * 2 // Exponential growth: 1, 2, 4, 8...
s.batchPending = count
s.batchReady = 0
}
// ============================================================================
// Fetcher
// ============================================================================
type Fetcher struct {
ctl *controller.Controller
config *config
doneCh chan error
meta *fetcher.FetcherMeta
// State machine
state atomic.Int32 // fetcherState
// Connections
connMu sync.Mutex
connections []*connection
resolveConn *connection // The special resolve connection
// Slow start controller
slowStart *slowStartController
// Max connection time for adaptive timeout (stored as int64 nanoseconds for atomic ops)
maxConnTime atomic.Int64
// First primary connection success signal
primaryReadyOnce sync.Once
primaryReadyCh chan struct{}
// Start pending mechanism
startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes
resolvedOnce sync.Once
resolveDataPos atomic.Int64 // How many bytes downloaded during resolve
// Resolve response - kept open for one-time URLs
resolveResp *http.Response
resolveRespLock sync.Mutex
// Async prefetch during resolve phase
prefetchFile *os.File // Temporary file for prefetch data
prefetchFilePath string // Path to temporary file
prefetchSize atomic.Int64 // Bytes prefetched so far
prefetchDone atomic.Bool // Prefetch completed or stopped
prefetchErr error // Error during prefetch (if any)
prefetchStopCh chan struct{} // Signal to stop prefetch
// Target file
file *os.File
fileMu sync.Mutex
redirectURL string
redirectLock sync.Mutex
// Lifecycle control
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// downloadLoop lifecycle tracking
downloadLoopDone chan struct{} // Closed when downloadLoop exits
// Resolve connection control
resolveCtx context.Context
resolveCancel context.CancelFunc
}
func (f *Fetcher) Setup(ctl *controller.Controller) {
f.ctl = ctl
f.doneCh = make(chan error, 1)
if f.meta == nil {
f.meta = &fetcher.FetcherMeta{}
}
f.ctl.GetConfig(&f.config)
f.resolvedCh = make(chan struct{})
f.primaryReadyCh = make(chan struct{})
// Check if this is a restore scenario (has existing connections or meta)
if f.meta.Res != nil {
// Already resolved, close the channel immediately
close(f.resolvedCh)
f.state.Store(int32(stateResolved))
} else {
f.state.Store(int32(stateIdle))
}
}
func (f *Fetcher) getState() fetcherState {
return fetcherState(f.state.Load())
}
func (f *Fetcher) setState(s fetcherState) {
f.state.Store(int32(s))
}
// updateMaxConnTime updates maxConnTime if the new duration is larger
func (f *Fetcher) updateMaxConnTime(d time.Duration) {
newVal := int64(d)
if newVal > f.maxConnTime.Load() {
f.maxConnTime.Store(newVal)
}
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
return err
}
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
// Parse options
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
return err
}
if opts.Extra == nil {
opts.Extra = &fhttp.OptsExtra{}
}
extra := opts.Extra.(*fhttp.OptsExtra)
if extra.Connections <= 0 {
extra.Connections = f.config.Connections
if extra.Connections <= 0 {
extra.Connections = 1
}
}
f.setState(stateResolving)
// Build HTTP request WITHOUT Range header (normal request)
// This allows the response to be reused for downloading (important for one-time URLs)
httpReq, err := f.buildRequest(context.TODO(), req)
if err != nil {
f.setState(stateError)
return err
}
client := f.buildClient()
// Send normal HTTP request (no Range header)
// Track connection time for adaptive timeout in download phase
connStartTime := time.Now()
resp, err := client.Do(httpReq)
if err != nil {
f.setState(stateError)
return err
}
// Record connection time as baseline for fast-fail timeout
f.updateMaxConnTime(time.Since(connStartTime))
// Parse response to get resource info
res := &base.Resource{
Range: false,
Files: []*base.FileInfo{},
}
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
resp.Body.Close()
f.setState(stateError)
return NewRequestError(resp.StatusCode)
}
// Check if server supports range requests
acceptRanges := resp.Header.Get(base.HttpHeaderAcceptRanges)
contentRange := resp.Header.Get(base.HttpHeaderContentRange)
if acceptRanges == base.HttpHeaderBytes || strings.HasPrefix(contentRange, base.HttpHeaderBytes) {
res.Range = true
}
// Get content length from Content-Length header
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
if contentLength != "" {
parse, err := strconv.ParseInt(contentLength, 10, 64)
if err == nil {
res.Size = parse
}
}
// Parse last modified time
var lastModifiedTime *time.Time
lastModified := resp.Header.Get(base.HttpHeaderLastModified)
if lastModified != "" {
t, _ := time.Parse(time.RFC1123, lastModified)
lastModifiedTime = &t
}
file := &base.FileInfo{
Size: res.Size,
Ctime: lastModifiedTime,
}
// Parse filename
contentDisposition := resp.Header.Get(base.HttpHeaderContentDisposition)
if contentDisposition != "" {
file.Name = parseFilename(contentDisposition)
}
if file.Name == "" {
file.Name = path.Base(httpReq.URL.Path)
if file.Name != "" {
// Use PathUnescape instead of QueryUnescape to correctly handle %2B (should decode to +, not space)
file.Name, _ = url.PathUnescape(file.Name)
}
}
if file.Name == "" || file.Name == "/" || file.Name == "." {
file.Name = httpReq.URL.Hostname()
}
res.Files = append(res.Files, file)
f.meta.Res = res
// Save redirect URL for later connections
f.redirectURL = resp.Request.URL.String()
// IMPORTANT: Keep the response body open for downloading in Start phase
// This is crucial for one-time URLs that can only be accessed once
f.resolveRespLock.Lock()
f.resolveResp = resp
f.resolveRespLock.Unlock()
f.setState(stateResolved)
// Signal that resolve is complete
f.resolvedOnce.Do(func() {
close(f.resolvedCh)
})
// Start async prefetch in background (only for range-supported resources)
// For non-range resources, the response will be used directly in Start
if res.Range && res.Size > 0 {
f.prefetchStopCh = make(chan struct{})
go f.asyncPrefetch()
}
// If start was called before resolve completed, auto-start
if f.startPending.Load() {
go f.doStart()
}
return nil
}
// asyncPrefetch downloads data in background during resolve phase
// This data can be reused when Start is called to save time
func (f *Fetcher) asyncPrefetch() {
defer func() {
f.prefetchDone.Store(true)
}()
// Get the resolve response
f.resolveRespLock.Lock()
resp := f.resolveResp
f.resolveRespLock.Unlock()
if resp == nil {
return
}
// Create temporary file for prefetch data
tmpFile, err := os.CreateTemp("", "gopeed-prefetch-*")
if err != nil {
f.prefetchErr = err
return
}
f.prefetchFile = tmpFile
f.prefetchFilePath = tmpFile.Name()
defer func() {
// Close response body when prefetch stops
f.resolveRespLock.Lock()
if f.resolveResp != nil {
f.resolveResp.Body.Close()
f.resolveResp = nil
}
f.resolveRespLock.Unlock()
}()
buf := make([]byte, 32*1024) // 32KB buffer
reader := NewTimeoutReader(resp.Body, readTimeout)
for {
select {
case <-f.prefetchStopCh:
// Stop signal received (Start was called)
return
default:
}
n, err := reader.Read(buf)
if n > 0 {
_, writeErr := tmpFile.Write(buf[:n])
if writeErr != nil {
f.prefetchErr = writeErr
return
}
f.prefetchSize.Add(int64(n))
}
if err != nil {
if err == io.EOF {
// Prefetch completed
return
}
f.prefetchErr = err
return
}
}
}
// stopPrefetchAndGetData stops the async prefetch and returns prefetched bytes
// It also copies prefetched data to the target file
func (f *Fetcher) stopPrefetchAndCopyData() int64 {
// Signal prefetch to stop (safely)
if f.prefetchStopCh != nil {
select {
case <-f.prefetchStopCh:
// Already closed
default:
close(f.prefetchStopCh)
}
}
// Wait for prefetch to finish (with timeout)
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {
time.Sleep(10 * time.Millisecond)
}
prefetched := f.prefetchSize.Load()
if prefetched == 0 {
f.cleanupPrefetchFile()
return 0
}
// Copy prefetch data to target file
if f.prefetchFile != nil && f.file != nil {
// Seek to beginning of prefetch file
f.prefetchFile.Seek(0, io.SeekStart)
// Copy to target file at position 0
buf := make([]byte, 32*1024)
var copied int64
for copied < prefetched {
n, err := f.prefetchFile.Read(buf)
if n > 0 {
f.file.WriteAt(buf[:n], copied)
copied += int64(n)
}
if err != nil {
break
}
}
}
f.cleanupPrefetchFile()
return prefetched
}
// cleanupPrefetchFile closes and removes the prefetch temporary file
func (f *Fetcher) cleanupPrefetchFile() {
if f.prefetchFile != nil {
f.prefetchFile.Close()
f.prefetchFile = nil
}
if f.prefetchFilePath != "" {
os.Remove(f.prefetchFilePath)
f.prefetchFilePath = ""
}
}
func (f *Fetcher) Start() error {
state := f.getState()
switch state {
case stateResolved, statePaused:
// Normal case: resolved or resuming from pause
return f.doStart()
case stateResolving:
// Early start: mark pending and return immediately
f.startPending.Store(true)
return nil
case stateSlowStart, stateSteady:
// Already downloading, this is a resume from pause
return f.doStart()
case stateError:
// Retry after error: reset and restart
return f.doStart()
default:
return fmt.Errorf("cannot start in current state: %v", state)
}
}
func (f *Fetcher) doStart() error {
// Wait for resolve to complete
<-f.resolvedCh
state := f.getState()
if state == stateDone {
return nil
}
// If retrying after error, reset connection states for retry
if state == stateError {
// Drain any pending error from doneCh before retry
select {
case <-f.doneCh:
default:
}
f.connMu.Lock()
for _, conn := range f.connections {
// Reset connections that can be retried
if !conn.Completed && conn.State != connCompleted {
conn.State = connNotStarted
conn.failed = false
conn.retryTimes = 0
conn.lastErr = nil
}
}
f.connMu.Unlock()
}
// Open or create target file first (needed for prefetch copy)
name := f.meta.SingleFilepath()
var err error
var file *os.File
_, err = os.Stat(name)
if err != nil {
if os.IsNotExist(err) {
file, err = f.ctl.Touch(name, f.meta.Res.Size)
} else {
return err
}
} else {
file, err = os.OpenFile(name, os.O_RDWR, os.ModeAppend)
}
if err != nil {
return err
}
f.fileMu.Lock()
f.file = file
f.fileMu.Unlock()
// For range-supported resources, stop prefetch and copy data
// For non-range resources, the response will be used directly
var prefetchedBytes int64
if f.meta.Res.Range {
// Stop async prefetch and copy data to target file
prefetchedBytes = f.stopPrefetchAndCopyData()
f.resolveDataPos.Store(prefetchedBytes)
// Also close resolve response if still open
f.resolveRespLock.Lock()
if f.resolveResp != nil {
f.resolveResp.Body.Close()
f.resolveResp = nil
}
f.resolveRespLock.Unlock()
}
// Avoid request extra modified by extension
if err = base.ParseReqExtra[fhttp.ReqExtra](f.meta.Req); err != nil {
return err
}
// Initialize slow start controller
maxConns := f.meta.Opts.Extra.(*fhttp.OptsExtra).Connections
f.slowStart = newSlowStartController(maxConns)
// Create main context
f.ctx, f.cancel = context.WithCancel(context.Background())
// Create downloadLoop lifecycle channel
f.downloadLoopDone = make(chan struct{})
// Start download
f.setState(stateSlowStart)
go f.downloadLoop()
return nil
}
func (f *Fetcher) downloadLoop() {
defer func() {
// Update file last modified time before closing
if f.config.UseServerCtime && f.meta.Res.Files[0].Ctime != nil {
setft.SetFileTime(f.meta.SingleFilepath(), time.Now(), *f.meta.Res.Files[0].Ctime, *f.meta.Res.Files[0].Ctime)
}
// Signal that downloadLoop has exited
if f.downloadLoopDone != nil {
close(f.downloadLoopDone)
}
}()
// Check if this is a resume or fresh start
isResume := len(f.connections) > 0
if !isResume {
// Fresh start: begin with resolve connection
f.startResolveDownload()
} else {
// Resume: restart existing connections
f.resumeConnections()
f.waitForCompletion()
return
}
// Slow start loop
for {
select {
case <-f.ctx.Done():
// Paused or cancelled
return
case <-f.slowStart.expansionCh:
// Batch completed, try to expand
if f.checkCompletion() {
// All work is done, wait for connections to finish
f.waitForCompletion()
return
}
f.expandConnections()
// Check if we've reached steady state (max connections)
if f.getState() == stateSteady {
// Wait for all connections to complete
f.waitForCompletion()
return
}
}
}
}
func (f *Fetcher) startResolveDownload() {
// If no range support or size unknown, just use single connection with resolve response
if !f.meta.Res.Range || f.meta.Res.Size == 0 {
// Create a single connection for the entire file
conn := &connection{
ID: 0,
Role: rolePrimary,
State: connNotStarted,
Chunk: newChunk(0, 0), // For non-range, end doesn't matter
}
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
f.connections = append(f.connections, conn)
f.wg.Add(1)
// Use the resolve response directly
go f.runConnectionWithResolveResp(conn)
// For non-range downloads, wait for completion directly in this goroutine
// Don't create another goroutine to avoid WaitGroup reuse issues
f.waitForCompletion()
return
}
// Range supported: use slow start to launch connections
// Start first batch of connections
f.expandConnections()
}
func (f *Fetcher) expandConnections() {
batchSize := f.slowStart.getNextBatchSize()
if batchSize <= 0 {
// Max reached, transition to steady state
f.setState(stateSteady)
// Don't start a new goroutine - let the downloadLoop handle completion
// This avoids multiple goroutines calling wg.Wait() simultaneously
return
}
totalSize := f.meta.Res.Size
f.connMu.Lock()
// For first batch (no existing connections), allocate the remaining file to first connection
if len(f.connections) == 0 {
// Check if we have prefetched data
prefetched := f.resolveDataPos.Load()
// If prefetched all data, mark as done
if prefetched >= totalSize {
f.connMu.Unlock()
// Close the file before signaling completion
f.fileMu.Lock()
if f.file != nil {
f.file.Close()
f.file = nil
}
f.fileMu.Unlock()
f.setState(stateDone)
f.doneCh <- nil
return
}
// First connection starts from prefetched position
conn := &connection{
ID: 0,
Role: rolePrimary,
State: connNotStarted,
Chunk: newChunk(prefetched, totalSize-1),
}
// Mark prefetched bytes as already downloaded
conn.Chunk.Downloaded = 0 // Start fresh from prefetched position
conn.Downloaded = prefetched // Track total downloaded including prefetch
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
f.connections = append(f.connections, conn)
f.connMu.Unlock()
f.slowStart.commitBatch(1)
f.wg.Add(1)
go f.runConnection(conn)
return
}
// For subsequent batches, use "help other connection" strategy
// Find connections with enough remaining work to split
// During slow start, use fixed minimum size since speed is not yet stable
minSplitSize := int64(stealMinChunkSize)
newConns := make([]*connection, 0, batchSize)
for i := 0; i < batchSize; i++ {
// Find the connection with most remaining work
var maxRemainConn *connection
var maxRemain int64
for _, conn := range f.connections {
if conn.Completed || conn.State == connFailed {
continue
}
remain := conn.Chunk.remain()
// Only split if remaining work is at least 2x the minimum split size
if remain > maxRemain && remain > minSplitSize*2 {
maxRemainConn = conn
maxRemain = remain
}
}
if maxRemainConn == nil {
// No connection has enough work to split
break
}
// Split the work: new connection takes the latter half
splitPoint := maxRemainConn.Chunk.End - maxRemainConn.Chunk.remain()/2
newChunk := newChunk(splitPoint+1, maxRemainConn.Chunk.End)
maxRemainConn.Chunk.End = splitPoint
connID := len(f.connections)
conn := &connection{
ID: connID,
Role: roleWorker,
State: connNotStarted,
Chunk: newChunk,
}
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
newConns = append(newConns, conn)
f.connections = append(f.connections, conn)
}
f.connMu.Unlock()
if len(newConns) == 0 {
// No new connections could be created, stop expansion.
// The downloadLoop will check stateSteady and call waitForCompletion.
f.setState(stateSteady)
return
}
// Commit batch to slow start controller
f.slowStart.commitBatch(len(newConns))
// Launch connections
for _, conn := range newConns {
f.wg.Add(1)
go f.runConnection(conn)
}
}
func (f *Fetcher) runConnection(conn *connection) {
defer f.wg.Done()
f.connMu.Lock()
conn.State = connConnecting
f.connMu.Unlock()
// Use fast-fail client for quick retry during download phase
client := f.buildFastFailClient()
buf := make([]byte, 8192)
retries := 0
conn.retryTimes = 0
for {
// Rebuild client with updated fast-fail timeout on retries
if retries > 0 {
client = f.buildFastFailClient()
}
err := f.downloadChunkOnce(conn, client, buf)
if err == nil {
if !f.meta.Res.Range || !f.helpOtherConnection(conn) {
f.connMu.Lock()
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
// Reset counters after a successful help switch
retries = 0
conn.retryTimes = 0
continue
}
if errors.Is(err, context.Canceled) {
return
}
if re := extractRequestError(err); re != nil {
conn.lastErr = re
} else {
conn.lastErr = err
}
if shouldCountHTTPFailure(err) {
if re := extractRequestError(err); re != nil && re.Code == 403 {
f.connMu.Lock()
conn.State = connFailed
conn.failed = true
f.connMu.Unlock()
if f.slowStart != nil {
f.slowStart.onConnectFailed()
}
return
}
conn.retryTimes++
f.connMu.Lock()
conn.failed = true
f.connMu.Unlock()
if f.slowStart != nil {
f.slowStart.onConnectFailed()
}
if conn.retryTimes >= 3 {
f.connMu.Lock()
conn.State = connFailed
f.connMu.Unlock()
return
}
}
f.connMu.Lock()
conn.State = connFailed
f.connMu.Unlock()
retryDelay := time.Second * time.Duration(retries+1)
if retryDelay > 5*time.Second {
retryDelay = 5 * time.Second
}
retries++
time.Sleep(retryDelay)
}
}
// downloadChunkOnce performs a single HTTP request for the current chunk without retrying.
// If the redirect URL fails with an expiration-related error (401, 403, 410),
// it will automatically retry with the original URL and update the redirect URL on success.
func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf []byte) error {
if conn.ctx.Err() != nil {
return conn.ctx.Err()
}
// Read chunk boundaries under lock to get a consistent snapshot
// This protects against concurrent modification by helpOtherConnection
f.connMu.Lock()
if f.meta.Res.Range && conn.Chunk.remain() <= 0 {
f.connMu.Unlock()
return nil
}
// For non-range downloads, reset chunk state since server will send from beginning.
// Without Range support, each new request restarts the entire transfer.
if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
conn.Downloaded = 0
}
rangeStart := conn.Chunk.Begin + conn.Chunk.Downloaded
rangeEnd := conn.Chunk.End
f.connMu.Unlock()
httpReq, err := f.buildRequest(conn.ctx, f.meta.Req)
if err != nil {
return err
}
if f.meta.Res.Range {
httpReq.Header.Set(base.HttpHeaderRange,
fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))
}
// Record connection start time for adaptive timeout tracking
connStartTime := time.Now()
resp, err := client.Do(httpReq)
if err != nil {
return err
}
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
resp.Body.Close()
originalErr := NewRequestError(resp.StatusCode)
// Check if this might be a redirect URL expiration error
// If so, try falling back to the original URL
if f.hasRedirectURL() && isRedirectExpiredError(originalErr) {
fallbackResp, fallbackErr := f.tryFallbackToOriginalURL(conn.ctx, client, rangeStart, rangeEnd)
if fallbackErr == nil && fallbackResp != nil {
// Fallback succeeded, use this response instead
resp = fallbackResp
// Update the redirect URL from the response
if resp.Request != nil && resp.Request.URL != nil {
f.updateRedirectURL(resp.Request.URL.String())
}
} else {
// Fallback also failed, return the original error
if fallbackResp != nil {
fallbackResp.Body.Close()
}
return originalErr
}
} else {
return originalErr
}
}
defer resp.Body.Close()
// Record successful connection time for adaptive timeout
f.updateMaxConnTime(time.Since(connStartTime))
f.connMu.Lock()
conn.State = connDownloading
conn.failed = false
f.connMu.Unlock()
if conn.Role == rolePrimary || conn.ID == 0 {
f.primaryReadyOnce.Do(func() {
close(f.primaryReadyCh)
})
}
if f.slowStart != nil {
f.slowStart.onConnectSuccess()
}
reader := NewTimeoutReader(resp.Body, readTimeout)
for {
if conn.ctx.Err() != nil {
return conn.ctx.Err()
}
n, err := reader.Read(buf)
if n > 0 {
finished := false
var writeOffset int64
// Lock to safely read chunk state and calculate write parameters
// This protects against concurrent chunk splitting by helpOtherConnection
f.connMu.Lock()
if f.meta.Res.Range {
// Check current chunk boundaries - this respects any concurrent chunk splitting
remain := conn.Chunk.remain()
if remain <= 0 {
// Chunk has been fully downloaded (possibly split and reduced)
f.connMu.Unlock()
return nil
}
if remain < int64(n) {
n = int(remain)
finished = true
}
} else if f.meta.Res.Size > 0 {
// For non-range downloads with known size, enforce size limit
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
f.connMu.Unlock()
return nil
}
if remaining < int64(n) {
n = int(remaining)
finished = true
}
}
writeOffset = conn.Chunk.Begin + conn.Chunk.Downloaded
f.connMu.Unlock()
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], writeOffset)
if writeErr != nil {
f.fileMu.Unlock()
return writeErr
}
}
f.fileMu.Unlock()
// Lock again to update Downloaded atomically with the read above
f.connMu.Lock()
conn.Chunk.Downloaded += int64(n)
conn.Downloaded += int64(n)
// Update connection speed periodically
now := time.Now().UnixNano()
if conn.lastSpeedCheck == 0 {
conn.lastSpeedCheck = now
conn.lastSpeedDownload = conn.Downloaded
} else if now-conn.lastSpeedCheck >= int64(500*time.Millisecond) {
elapsed := float64(now-conn.lastSpeedCheck) / float64(time.Second)
if elapsed > 0 {
conn.speed = int64(float64(conn.Downloaded-conn.lastSpeedDownload) / elapsed)
}
conn.lastSpeedCheck = now
conn.lastSpeedDownload = conn.Downloaded
}
f.connMu.Unlock()
if finished {
return nil
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// runConnectionWithResolveResp uses the response body from Resolve phase
// This is crucial for one-time URLs that can only be accessed once
func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
defer f.wg.Done()
f.connMu.Lock()
conn.State = connConnecting
f.connMu.Unlock()
buf := make([]byte, 8192)
// Get the resolve response
f.resolveRespLock.Lock()
resp := f.resolveResp
f.resolveResp = nil // Take ownership
f.resolveRespLock.Unlock()
if resp == nil {
// No resolve response available, fall back to normal connection
f.runConnectionFallback(conn)
return
}
defer resp.Body.Close()
f.connMu.Lock()
conn.State = connDownloading
conn.failed = false
f.connMu.Unlock()
// Signal primary ready
f.primaryReadyOnce.Do(func() {
close(f.primaryReadyCh)
})
if f.slowStart != nil {
f.slowStart.onConnectSuccess()
}
// Download data from resolve response
reader := NewTimeoutReader(resp.Body, readTimeout)
for {
if conn.ctx.Err() != nil {
return
}
n, err := reader.Read(buf)
if n > 0 {
// For non-range downloads with known size, enforce size limit
if !f.meta.Res.Range && f.meta.Res.Size > 0 {
f.connMu.Lock()
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
if remaining < int64(n) {
n = int(remaining)
}
f.connMu.Unlock()
}
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)
if writeErr != nil {
f.fileMu.Unlock()
f.connMu.Lock()
conn.State = connFailed
conn.failed = true
f.connMu.Unlock()
if f.slowStart != nil {
f.slowStart.onConnectFailed()
}
return
}
}
f.fileMu.Unlock()
f.connMu.Lock()
conn.Chunk.Downloaded += int64(n)
conn.Downloaded += int64(n)
// Check if size limit reached after updating counters
if !f.meta.Res.Range && f.meta.Res.Size > 0 && conn.Downloaded >= f.meta.Res.Size {
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
f.connMu.Unlock()
}
if err != nil {
if err == io.EOF {
f.connMu.Lock()
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
// Reading from resolve response failed: treat as transient (do not count as fail)
f.connMu.Lock()
conn.State = connFailed
f.connMu.Unlock()
return
}
}
}
// runConnectionFallback is used when resolve response is not available
func (f *Fetcher) runConnectionFallback(conn *connection) {
// Use fast-fail client for quick retry during download phase
client := f.buildFastFailClient()
buf := make([]byte, 8192)
retries := 0
countedRetries := 0
for {
if conn.ctx.Err() != nil {
return
}
// Rebuild client with updated fast-fail timeout on retries
if retries > 0 {
client = f.buildFastFailClient()
}
f.connMu.Lock()
conn.State = connConnecting
// For non-range downloads, reset chunk state since server will send from beginning.
// Without Range support, each new request restarts the entire transfer.
if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
conn.Downloaded = 0
}
f.connMu.Unlock()
err := func() error {
httpReq, err := f.buildRequest(conn.ctx, f.meta.Req)
if err != nil {
return err
}
// Record connection start time for adaptive timeout tracking
connStartTime := time.Now()
resp, err := client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
return NewRequestError(resp.StatusCode)
}
// Record successful connection time for adaptive timeout
f.updateMaxConnTime(time.Since(connStartTime))
f.connMu.Lock()
conn.State = connDownloading
conn.failed = false
f.connMu.Unlock()
f.primaryReadyOnce.Do(func() {
close(f.primaryReadyCh)
})
if f.slowStart != nil {
f.slowStart.onConnectSuccess()
}
reader := NewTimeoutReader(resp.Body, readTimeout)
for {
if conn.ctx.Err() != nil {
return conn.ctx.Err()
}
n, err := reader.Read(buf)
if n > 0 {
// For non-range downloads with known size, enforce size limit
if !f.meta.Res.Range && f.meta.Res.Size > 0 {
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
return nil
}
if remaining < int64(n) {
n = int(remaining)
}
}
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)
if writeErr != nil {
f.fileMu.Unlock()
return writeErr
}
}
f.fileMu.Unlock()
conn.Chunk.Downloaded += int64(n)
conn.Downloaded += int64(n)
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}()
if err == nil {
f.connMu.Lock()
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
if errors.Is(err, context.Canceled) {
return
}
if re := extractRequestError(err); re != nil {
conn.lastErr = re
} else {
conn.lastErr = err
}
if shouldCountHTTPFailure(err) {
// Immediate fail for server connection limit (403)
if re := extractRequestError(err); re != nil && re.Code == 403 {
f.connMu.Lock()
conn.State = connFailed
conn.failed = true
f.connMu.Unlock()
if f.slowStart != nil {
f.slowStart.onConnectFailed()
}
return
}
conn.retryTimes++
countedRetries++
if countedRetries >= 3 {
f.connMu.Lock()
conn.State = connFailed
conn.failed = true
f.connMu.Unlock()
if f.slowStart != nil {
f.slowStart.onConnectFailed()
}
return
}
// Retry again for counted failures below the cap
f.connMu.Lock()
conn.State = connFailed
f.connMu.Unlock()
retryDelay := time.Second * time.Duration(retries+1)
if retryDelay > 5*time.Second {
retryDelay = 5 * time.Second
}
retries++
time.Sleep(retryDelay)
continue
}
// Retry indefinitely for non-counted errors
f.connMu.Lock()
conn.State = connFailed
f.connMu.Unlock()
retryDelay := time.Second * time.Duration(retries+1)
if retryDelay > 5*time.Second {
retryDelay = 5 * time.Second
}
retries++
time.Sleep(retryDelay)
}
}
// helpOtherConnection implements work stealing: when a connection finishes its chunk,
// it looks for connections that need more than stealThresholdSeconds to finish and steals half of its work.
func (f *Fetcher) helpOtherConnection(helper *connection) bool {
f.connMu.Lock()
defer f.connMu.Unlock()
// Find the connection with longest remaining time
var slowestConn *connection
var maxRemainSeconds int64
for _, r := range f.connections {
if r == helper || r.Completed || r.State == connFailed {
continue
}
remain := r.Chunk.remain()
if remain < stealMinChunkSize {
continue
}
// Calculate remaining time in seconds for this connection
var remainSeconds int64
if r.speed > 0 {
remainSeconds = remain / r.speed
} else {
// Speed unknown, assume it needs help if chunk is large enough
remainSeconds = stealThresholdSeconds + 1
}
// Only consider if it needs more than threshold seconds to finish
if remainSeconds > stealThresholdSeconds && remainSeconds > maxRemainSeconds {
slowestConn = r
maxRemainSeconds = remainSeconds
}
}
if slowestConn == nil {
return false
}
// Re-calculate the chunk range: steal half of the remaining work
helper.Chunk.Begin = slowestConn.Chunk.End - slowestConn.Chunk.remain()/2
helper.Chunk.End = slowestConn.Chunk.End
helper.Chunk.Downloaded = 0
slowestConn.Chunk.End = helper.Chunk.Begin - 1
return true
}
func (f *Fetcher) resumeConnections() {
// Collect connections to resume while holding the lock
var toResume []*connection
f.connMu.Lock()
for _, conn := range f.connections {
// Only skip connections that have truly completed successfully
if conn.Completed || conn.State == connCompleted {
continue
}
// For failed connections, skip if:
// 1. They have exhausted retries (retryTimes >= 3), OR
// 2. They failed with a permanent error like 403
if conn.State == connFailed && conn.failed {
// Check if it's a permanent error (like 403)
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
// Check if retries exhausted
if conn.retryTimes >= 3 {
continue
}
}
// Reset the connection state for resume
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
conn.State = connNotStarted
conn.failed = false // Clear failed flag for resumed connection
toResume = append(toResume, conn)
}
f.connMu.Unlock()
// Start connections outside the lock
for _, conn := range toResume {
f.wg.Add(1)
go f.runConnection(conn)
}
}
func (f *Fetcher) waitForCompletion() {
f.wg.Wait()
// Only trigger completion if not cancelled/paused
if f.ctx != nil && f.ctx.Err() == nil {
f.onDownloadComplete()
}
}
func (f *Fetcher) onDownloadComplete() {
f.connMu.Lock()
// First, check if download actually completed successfully
// Calculate total downloaded from all connections
totalDownloaded := int64(0)
if f.resolveConn != nil {
totalDownloaded += f.resolveConn.Downloaded
}
for _, conn := range f.connections {
totalDownloaded += conn.Downloaded
}
// Check if all chunks are complete (no remaining bytes)
allChunksComplete := true
for _, conn := range f.connections {
if !conn.Completed && conn.State != connCompleted {
if f.meta.Res.Range {
// For range downloads, use chunk remain() to check completion
if conn.Chunk != nil && conn.Chunk.remain() > 0 {
// This connection has remaining work and isn't done
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
}
allChunksComplete = false
break
}
} else {
// For non-range downloads, completion is determined solely by EOF (conn.Completed).
// chunk.remain() is unreliable for non-range chunks (Begin=0, End=0).
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
}
allChunksComplete = false
break
}
}
}
// If total downloaded matches file size, consider it a success regardless of connection failures
downloadComplete := f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size
// Check for any errors, but ignore 403 (server connection limit) errors if download completed
var finalErr error
if !downloadComplete && !allChunksComplete {
for _, conn := range f.connections {
if conn.State == connFailed && conn.failed {
// Skip 403 errors (server connection limit) - these are expected when exceeding server's limit
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
if re := extractRequestError(conn.lastErr); re != nil {
finalErr = fmt.Errorf("connection %d failed: retries=%d, status=%d", conn.ID, conn.retryTimes, re.Code)
} else if conn.lastErr != nil {
finalErr = fmt.Errorf("connection %d failed: retries=%d, err=%v", conn.ID, conn.retryTimes, conn.lastErr)
} else {
finalErr = fmt.Errorf("connection %d failed: retries=%d", conn.ID, conn.retryTimes)
}
break
}
}
}
f.connMu.Unlock()
// Close the file before signaling completion
// This ensures the file handle is released before Wait() returns
f.fileMu.Lock()
if f.file != nil {
f.file.Close()
f.file = nil
}
f.fileMu.Unlock()
if finalErr != nil {
f.setState(stateError)
} else {
f.setState(stateDone)
}
select {
case f.doneCh <- finalErr:
default:
}
}
func (f *Fetcher) checkCompletion() bool {
// Check if all data has been downloaded
f.connMu.Lock()
defer f.connMu.Unlock()
totalDownloaded := int64(0)
if f.resolveConn != nil {
totalDownloaded += f.resolveConn.Downloaded
}
for _, conn := range f.connections {
totalDownloaded += conn.Downloaded
}
if f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size {
// Don't start a new goroutine - let the caller handle completion
return true
}
// Check if all connections completed
allCompleted := true
if f.resolveConn != nil && !f.resolveConn.Completed && f.resolveConn.State != connCompleted {
allCompleted = false
}
for _, conn := range f.connections {
if !conn.Completed && conn.State != connCompleted && conn.State != connFailed {
allCompleted = false
break
}
}
if allCompleted {
// Don't start a new goroutine - let the caller handle completion
return true
}
return false
}
// Patch modifies the HTTP request information.
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
// Patch request info
if req != nil {
if req.URL != "" {
f.meta.Req.URL = req.URL
// Clear redirect URL when URL is changed, so new requests use the new URL
f.updateRedirectURL("")
}
if req.Extra != nil {
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
return err
}
patchExtra := req.Extra.(*fhttp.ReqExtra)
// Merge Extra fields instead of replacing entirely
if f.meta.Req.Extra == nil {
f.meta.Req.Extra = &fhttp.ReqExtra{}
}
existingExtra := f.meta.Req.Extra.(*fhttp.ReqExtra)
// Update Method only if non-empty
if patchExtra.Method != "" {
existingExtra.Method = patchExtra.Method
}
// Update Body only if non-empty
if patchExtra.Body != "" {
existingExtra.Body = patchExtra.Body
}
// Merge Headers: existing keys are overwritten, new keys are added
if patchExtra.Header != nil {
if existingExtra.Header == nil {
existingExtra.Header = make(map[string]string)
}
for k, v := range patchExtra.Header {
existingExtra.Header[k] = v
}
}
}
// Merge Labels: existing keys are overwritten, new keys are added
if req.Labels != nil {
if f.meta.Req.Labels == nil {
f.meta.Req.Labels = make(map[string]string)
}
for k, v := range req.Labels {
f.meta.Req.Labels[k] = v
}
}
if req.Proxy != nil {
f.meta.Req.Proxy = req.Proxy
}
}
return nil
}
func (f *Fetcher) Pause() error {
if f.cancel != nil {
f.cancel()
}
if f.resolveCancel != nil {
f.resolveCancel()
}
// Stop prefetch if running
if f.prefetchStopCh != nil {
select {
case <-f.prefetchStopCh:
// Already closed
default:
close(f.prefetchStopCh)
}
}
// Wait for downloadLoop to exit first (it will call wg.Wait internally)
if f.downloadLoopDone != nil {
<-f.downloadLoopDone
}
// Wait for all connection goroutines to stop
f.wg.Wait()
// Wait for prefetch to finish
for f.prefetchStopCh != nil && !f.prefetchDone.Load() {
time.Sleep(10 * time.Millisecond)
}
// Clean up prefetch file
f.cleanupPrefetchFile()
// Clean up resolve response if still held
f.resolveRespLock.Lock()
if f.resolveResp != nil {
f.resolveResp.Body.Close()
f.resolveResp = nil
}
f.resolveRespLock.Unlock()
f.fileMu.Lock()
if f.file != nil {
f.file.Close()
f.file = nil
}
f.fileMu.Unlock()
f.setState(statePaused)
return nil
}
func (f *Fetcher) Close() error {
return f.Pause()
}
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Stats() any {
f.connMu.Lock()
defer f.connMu.Unlock()
statsConnections := make([]*fhttp.StatsConnection, 0)
for _, connection := range f.connections {
statsConnections = append(statsConnections, &fhttp.StatsConnection{
Downloaded: connection.Downloaded,
Completed: connection.Completed,
Failed: connection.failed,
RetryTimes: connection.retryTimes,
})
}
return &fhttp.Stats{
Connections: statsConnections,
}
}
func (f *Fetcher) Progress() fetcher.Progress {
p := make(fetcher.Progress, 0)
total := int64(0)
if f.resolveConn != nil {
total += f.resolveConn.Downloaded
}
f.connMu.Lock()
for _, conn := range f.connections {
total += conn.Downloaded
}
f.connMu.Unlock()
p = append(p, total)
return p
}
func (f *Fetcher) Wait() error {
return <-f.doneCh
}