mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
1762 lines
43 KiB
Go
1762 lines
43 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 {
|
|
f.resetConnectionForRestart(conn)
|
|
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
|
|
f.setState(stateSteady)
|
|
go f.waitForCompletion()
|
|
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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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 {
|
|
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()
|
|
|
|
conn.Chunk.Downloaded += int64(n)
|
|
conn.Downloaded += int64(n)
|
|
}
|
|
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
|
|
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 {
|
|
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) resetConnectionForRestart(conn *connection) {
|
|
if f.meta.Res.Range {
|
|
return
|
|
}
|
|
|
|
// Without range support a new request always starts from byte 0,
|
|
// so pause/retry must restart instead of continuing from the old offset.
|
|
if conn.Chunk == nil {
|
|
conn.Chunk = newChunk(0, 0)
|
|
} else {
|
|
conn.Chunk.Begin = 0
|
|
conn.Chunk.End = 0
|
|
conn.Chunk.Downloaded = 0
|
|
}
|
|
conn.Downloaded = 0
|
|
conn.Completed = false
|
|
conn.speed = 0
|
|
conn.lastSpeedCheck = 0
|
|
conn.lastSpeedDownload = 0
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
f.resetConnectionForRestart(conn)
|
|
// 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 {
|
|
needsMoreData := false
|
|
if f.meta.Res.Range {
|
|
needsMoreData = conn.Chunk != nil && conn.Chunk.remain() > 0
|
|
} else if f.meta.Res.Size > 0 {
|
|
needsMoreData = conn.Downloaded < f.meta.Res.Size
|
|
} else {
|
|
needsMoreData = !conn.Completed && conn.State != connCompleted
|
|
}
|
|
|
|
if needsMoreData && !conn.Completed && conn.State != connCompleted {
|
|
// 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 {
|
|
// 403 is server connection limit, check if other connections will complete this chunk
|
|
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
|
|
}
|