mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 07:45:30 +00:00
2453 lines
67 KiB
Go
2453 lines
67 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/internal/httpclient"
|
|
"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)
|
|
)
|
|
|
|
var (
|
|
errRangeRequestIgnored = errors.New("server ignored HTTP range request")
|
|
errInvalidRangeResponse = errors.New("invalid HTTP range response")
|
|
errIncompleteResponse = errors.New("incomplete HTTP response")
|
|
)
|
|
|
|
func isRangeIntegrityError(err error) bool {
|
|
return errors.Is(err, errRangeRequestIgnored) || errors.Is(err, errInvalidRangeResponse)
|
|
}
|
|
|
|
func isTerminalRangeError(err error) bool {
|
|
return isRangeIntegrityError(err)
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 // nil until an open-ended Range response reveals the total size
|
|
Downloaded int64
|
|
}
|
|
|
|
func (c *chunk) remain() int64 {
|
|
return *c.End - c.Begin + 1 - c.Downloaded
|
|
}
|
|
|
|
func (c *chunk) openEnded() bool {
|
|
return c.End == nil
|
|
}
|
|
|
|
func (c *chunk) setEnd(end int64) {
|
|
c.End = &end
|
|
}
|
|
|
|
func newChunk(begin int64, end int64) *chunk {
|
|
return &chunk{
|
|
Begin: begin,
|
|
End: &end,
|
|
}
|
|
}
|
|
|
|
func newOpenEndedChunk(begin int64) *chunk {
|
|
return &chunk{
|
|
Begin: begin,
|
|
}
|
|
}
|
|
|
|
type connection struct {
|
|
ID int
|
|
Role connectionRole
|
|
State connectionState
|
|
Chunk *chunk
|
|
Downloaded int64
|
|
Completed bool
|
|
|
|
failed bool
|
|
exited 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
|
|
}
|
|
|
|
func (s *slowStartController) signalExpansion() {
|
|
select {
|
|
case s.expansionCh <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Fetcher
|
|
// ============================================================================
|
|
|
|
type Fetcher struct {
|
|
ctl *controller.Controller
|
|
config *config
|
|
doneCh chan error
|
|
|
|
impersonationSession *httpclient.ImpersonationSession
|
|
|
|
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
|
|
resolveFallback atomic.Bool // Resolve prefetch is still available until the first Range request takes over
|
|
|
|
// Target file
|
|
file *os.File
|
|
fileMu sync.Mutex
|
|
redirectURL string
|
|
redirectLock sync.Mutex
|
|
ifRange string
|
|
rangeReprobeEligible bool
|
|
rangeValidatorPinned bool
|
|
sequentialSizeUnknown bool
|
|
|
|
// 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)
|
|
if f.impersonationSession == nil {
|
|
f.impersonationSession = httpclient.NewImpersonationSession()
|
|
}
|
|
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 != "" {
|
|
if t, err := http.ParseTime(lastModified); err == nil {
|
|
lastModifiedTime = &t
|
|
}
|
|
}
|
|
f.ifRange = extractIfRangeValidator(resp.Header)
|
|
|
|
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 {
|
|
f.prefetchStopCh = make(chan struct{})
|
|
f.resolveFallback.Store(true)
|
|
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 = ""
|
|
}
|
|
// The byte count is only reusable while the backing prefetch file exists.
|
|
// Pause may discard that file before the first Start, so retaining the
|
|
// count would make the next Start skip bytes that were never copied.
|
|
f.prefetchSize.Store(0)
|
|
f.resolveFallback.Store(false)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Each Start/Wait cycle must observe only its own completion result.
|
|
// A paused or failed cycle may have completed concurrently with Pause and
|
|
// left a result buffered before its download loop fully stopped.
|
|
if state == statePaused || state == stateError {
|
|
select {
|
|
case <-f.doneCh:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// If retrying after error, reset connection states for retry
|
|
if state == stateError {
|
|
f.connMu.Lock()
|
|
for _, conn := range f.connections {
|
|
// Reset connections that can be retried
|
|
if !conn.Completed && conn.State != connCompleted {
|
|
if !f.hasSequentialPrefixLocked(conn) {
|
|
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 {
|
|
resolveComplete := f.meta.Res.Size > 0 && f.prefetchDone.Load() && f.prefetchErr == nil && f.prefetchSize.Load() >= f.meta.Res.Size
|
|
if f.resolveFallback.Load() && !resolveComplete {
|
|
// Keep Resolve downloading until the first Range response has been
|
|
// validated. The Range request starts at a stable snapshot; a valid
|
|
// takeover discards any overlap downloaded by Resolve in the meantime.
|
|
prefetchedBytes = f.prefetchSize.Load()
|
|
} else {
|
|
// Resolve already completed, or no live Resolve fallback remains.
|
|
prefetchedBytes = f.stopPrefetchAndCopyData()
|
|
}
|
|
f.resolveDataPos.Store(prefetchedBytes)
|
|
|
|
if !f.resolveFallback.Load() {
|
|
// 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
|
|
downloadLoopDone := make(chan struct{})
|
|
f.downloadLoopDone = downloadLoopDone
|
|
|
|
// Start download
|
|
f.setState(stateSlowStart)
|
|
go f.downloadLoop(downloadLoopDone)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) downloadLoop(done chan struct{}) {
|
|
ctx := f.ctx
|
|
|
|
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 done != nil {
|
|
close(done)
|
|
}
|
|
}()
|
|
|
|
// Check if this is a resume or fresh start
|
|
isResume := len(f.connections) > 0
|
|
|
|
if !isResume {
|
|
// Capture the mode before launching the first connection. The server may
|
|
// ignore that connection's Range request and switch the fetcher to a
|
|
// sequential download while the request is in flight.
|
|
startedWithRanges := f.meta.Res.Range
|
|
// Fresh start: begin with resolve connection
|
|
f.startResolveDownload()
|
|
// Non-range downloads wait for their only connection in
|
|
// startResolveDownload. Falling through would consume the connection's
|
|
// expansion signal and publish the same completion a second time.
|
|
if !startedWithRanges || f.getState() == stateDone {
|
|
return
|
|
}
|
|
} else {
|
|
// A retained sequential prefix starts as the first slow-start batch. If
|
|
// its guarded request restores Range support, the normal expansion loop
|
|
// can split the remaining suffix across the configured connections.
|
|
canRecoverRange := f.hasSequentialRecoveryCandidate()
|
|
if canRecoverRange {
|
|
f.slowStart.commitBatch(1)
|
|
}
|
|
f.resumeConnections()
|
|
if !canRecoverRange {
|
|
f.waitForCompletion(ctx)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Slow start loop
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// Paused or cancelled
|
|
return
|
|
case <-f.slowStart.expansionCh:
|
|
f.connMu.Lock()
|
|
rangeMode := f.meta.Res.Range
|
|
sequentialExited := len(f.connections) == 1 && f.connections[0].exited
|
|
f.connMu.Unlock()
|
|
if !rangeMode {
|
|
// The primary response is still being consumed sequentially. A
|
|
// successful re-probe or final connection exit will signal again.
|
|
if sequentialExited {
|
|
f.waitForCompletion(ctx)
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
// Batch completed, try to expand
|
|
if f.checkCompletion() {
|
|
// All work is done, wait for connections to finish
|
|
f.waitForCompletion(ctx)
|
|
return
|
|
}
|
|
f.expandConnections()
|
|
|
|
// Check if we've reached steady state (max connections)
|
|
if f.getState() == stateSteady {
|
|
// Wait for all connections to complete
|
|
f.waitForCompletion(ctx)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) startResolveDownload() {
|
|
// Without Range support, consume the reusable Resolve response sequentially.
|
|
// A Range-capable resource with an unknown size instead starts one open-ended
|
|
// worker, which discovers the total from Content-Range before slow-start expands.
|
|
if !f.meta.Res.Range {
|
|
// 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.connMu.Lock()
|
|
f.connections = append(f.connections, conn)
|
|
f.connMu.Unlock()
|
|
|
|
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(f.ctx)
|
|
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. A zero size means the extent is
|
|
// still unknown, so the first worker must discover it with bytes=start-.
|
|
if totalSize > 0 && 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
|
|
connChunk := newOpenEndedChunk(prefetched)
|
|
if totalSize > 0 {
|
|
connChunk = newChunk(prefetched, totalSize-1)
|
|
}
|
|
conn := &connection{
|
|
ID: 0,
|
|
Role: rolePrimary,
|
|
State: connNotStarted,
|
|
Chunk: connChunk,
|
|
}
|
|
// 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
|
|
}
|
|
if conn.Chunk.openEnded() {
|
|
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
|
|
chunkEnd := *maxRemainConn.Chunk.End
|
|
splitPoint := chunkEnd - maxRemainConn.Chunk.remain()/2
|
|
newChunk := newChunk(splitPoint+1, chunkEnd)
|
|
maxRemainConn.Chunk.setEnd(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)
|
|
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 func() {
|
|
// A range-advertising origin can fall back to one sequential response.
|
|
// Wake the download loop after that connection exits because its earlier
|
|
// response-ready signal was intentionally not used to expand.
|
|
f.connMu.Lock()
|
|
conn.exited = true
|
|
sequentialRecovery := !f.meta.Res.Range && (f.rangeReprobeEligible || conn.Completed) && len(f.connections) == 1
|
|
f.connMu.Unlock()
|
|
f.wg.Done()
|
|
if sequentialRecovery && f.slowStart != nil {
|
|
f.slowStart.signalExpansion()
|
|
}
|
|
}()
|
|
|
|
f.connMu.Lock()
|
|
conn.exited = false
|
|
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
|
|
f.connMu.Lock()
|
|
conn.retryTimes = 0
|
|
f.connMu.Unlock()
|
|
|
|
for {
|
|
// Rebuild client with updated fast-fail timeout on retries
|
|
if retries > 0 {
|
|
client = f.buildFastFailClient()
|
|
|
|
// Preserve an eligible sequential prefix long enough to probe whether
|
|
// the origin has recovered byte-range support. downloadChunkOnce resets
|
|
// it only when no safe validator exists or If-Range returns a full 200.
|
|
f.connMu.Lock()
|
|
if !f.hasSequentialPrefixLocked(conn) {
|
|
f.resetConnectionForRestart(conn)
|
|
}
|
|
f.connMu.Unlock()
|
|
}
|
|
|
|
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
|
|
f.connMu.Lock()
|
|
conn.retryTimes = 0
|
|
f.connMu.Unlock()
|
|
continue
|
|
}
|
|
|
|
if errors.Is(err, context.Canceled) {
|
|
return
|
|
}
|
|
requestErr := extractRequestError(err)
|
|
shouldUseResolveFallback := requestErr == nil || !isFailureExemptHTTPCode(requestErr.Code)
|
|
if shouldUseResolveFallback && f.completeFromResolveFallback(conn) {
|
|
return
|
|
}
|
|
if isTerminalRangeError(err) {
|
|
f.connMu.Lock()
|
|
conn.lastErr = err
|
|
conn.State = connFailed
|
|
conn.failed = true
|
|
f.connMu.Unlock()
|
|
if f.slowStart != nil {
|
|
f.slowStart.onConnectFailed()
|
|
}
|
|
return
|
|
}
|
|
|
|
f.connMu.Lock()
|
|
if re := extractRequestError(err); re != nil {
|
|
conn.lastErr = re
|
|
} else {
|
|
conn.lastErr = err
|
|
}
|
|
f.connMu.Unlock()
|
|
|
|
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
|
|
}
|
|
f.connMu.Lock()
|
|
conn.retryTimes++
|
|
retryTimes := conn.retryTimes
|
|
conn.failed = true
|
|
f.connMu.Unlock()
|
|
if f.slowStart != nil {
|
|
f.slowStart.onConnectFailed()
|
|
}
|
|
if 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. A prefix
|
|
// without a safe validator cannot be resumed, so restart it before building
|
|
// the request; the returned full response will overwrite from byte zero.
|
|
f.connMu.Lock()
|
|
sequentialSizeUnknown := f.sequentialSizeUnknown
|
|
hasSequentialPrefix := f.hasSequentialPrefixLocked(conn)
|
|
resumeProbe := f.canProbeSequentialResumeLocked(conn)
|
|
intentionalRestart := sequentialSizeUnknown
|
|
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
|
|
f.resetConnectionForRestart(conn)
|
|
f.resolveDataPos.Store(0)
|
|
f.rangeValidatorPinned = false
|
|
intentionalRestart = true
|
|
}
|
|
openEndedRange := f.meta.Res.Range && conn.Chunk.openEnded()
|
|
if f.meta.Res.Range && !openEndedRange && conn.Chunk.remain() <= 0 {
|
|
f.connMu.Unlock()
|
|
return nil
|
|
}
|
|
rangeStart := conn.Chunk.Begin + conn.Chunk.Downloaded
|
|
var rangeEnd *int64
|
|
if !openEndedRange {
|
|
end := *conn.Chunk.End
|
|
rangeEnd = &end
|
|
}
|
|
rangeMode := f.meta.Res.Range
|
|
ifRange := ""
|
|
if resumeProbe {
|
|
end := f.meta.Res.Size - 1
|
|
rangeEnd = &end
|
|
ifRange = f.ifRange
|
|
} else if rangeMode && (f.rangeValidatorPinned || f.resolveFallback.Load()) {
|
|
ifRange = f.ifRange
|
|
}
|
|
f.connMu.Unlock()
|
|
|
|
httpReq, err := f.buildRequest(conn.ctx, f.meta.Req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if rangeMode || resumeProbe {
|
|
httpReq.Header.Set(base.HttpHeaderRange, formatRangeHeader(rangeStart, rangeEnd))
|
|
if ifRange != "" {
|
|
httpReq.Header.Set(base.HttpHeaderIfRange, ifRange)
|
|
}
|
|
}
|
|
rangeRequested := httpReq.Header.Get(base.HttpHeaderRange) != ""
|
|
|
|
// 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, rangeRequested, ifRange)
|
|
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
|
|
}
|
|
}
|
|
|
|
expectedResponseLength := int64(-1)
|
|
var responseBytesToDiscard int64
|
|
if rangeRequested && resp.StatusCode == base.HttpCodeOK {
|
|
if conn.ID == 0 && f.resolveFallback.CompareAndSwap(true, false) {
|
|
// This response is itself a complete representation, so the older
|
|
// Resolve stream is no longer needed. Stop and copy its prefix before
|
|
// the sequential restart truncates/overwrites the target from zero.
|
|
f.stopPrefetchAndCopyData()
|
|
}
|
|
if resumeProbe {
|
|
// If-Range returning 200 provides the complete current representation.
|
|
// Discard the old prefix counters and consume this response from byte 0.
|
|
f.restartSequentialDownload(conn, extractIfRangeValidator(resp.Header))
|
|
intentionalRestart = true
|
|
} else {
|
|
if err := f.fallbackToSequentialDownload(conn, extractIfRangeValidator(resp.Header)); err != nil {
|
|
resp.Body.Close()
|
|
return fmt.Errorf("%w: expected 206 Partial Content, got 200 OK", err)
|
|
}
|
|
intentionalRestart = true
|
|
}
|
|
}
|
|
if rangeRequested && resp.StatusCode == base.HttpCodePartialContent {
|
|
var responseTotal int64
|
|
expectedResponseLength, responseTotal, err = validateRangeResponse(resp, rangeStart, rangeEnd, f.meta.Res.Size)
|
|
if err != nil {
|
|
resp.Body.Close()
|
|
return err
|
|
}
|
|
if openEndedRange {
|
|
if err := f.resolveOpenEndedRange(conn, responseTotal); err != nil {
|
|
resp.Body.Close()
|
|
return err
|
|
}
|
|
}
|
|
responseBytesToDiscard, err = f.commitResolveTakeover(conn, rangeStart)
|
|
if err != nil {
|
|
resp.Body.Close()
|
|
return err
|
|
}
|
|
if resumeProbe {
|
|
// The origin recovered Range support. Convert the preserved sequential
|
|
// prefix into a normal ranged chunk before writing the suffix.
|
|
f.connMu.Lock()
|
|
f.meta.Res.Range = true
|
|
f.rangeReprobeEligible = false
|
|
f.rangeValidatorPinned = true
|
|
f.sequentialSizeUnknown = false
|
|
conn.Chunk.Begin = 0
|
|
conn.Chunk.setEnd(f.meta.Res.Size - 1)
|
|
f.connMu.Unlock()
|
|
}
|
|
}
|
|
if !rangeRequested && resp.StatusCode == base.HttpCodeOK {
|
|
f.connMu.Lock()
|
|
if f.rangeReprobeEligible {
|
|
// This full response might itself become the retained prefix if its
|
|
// body is interrupted, so bind any later If-Range probe to it.
|
|
f.ifRange = extractIfRangeValidator(resp.Header)
|
|
}
|
|
f.connMu.Unlock()
|
|
}
|
|
if !f.meta.Res.Range && resp.StatusCode == base.HttpCodeOK {
|
|
if intentionalRestart {
|
|
if resp.ContentLength >= 0 {
|
|
if err := f.resizeSequentialResource(resp.ContentLength); err != nil {
|
|
resp.Body.Close()
|
|
return err
|
|
}
|
|
expectedResponseLength = resp.ContentLength
|
|
} else if err := f.beginUnknownSizeSequentialResource(); err != nil {
|
|
resp.Body.Close()
|
|
return err
|
|
}
|
|
} else if f.meta.Res.Size > 0 {
|
|
expectedResponseLength = f.meta.Res.Size
|
|
if resp.ContentLength >= 0 && resp.ContentLength != expectedResponseLength {
|
|
resp.Body.Close()
|
|
return fmt.Errorf("%w: Content-Length %d does not match resource size %d", errInvalidRangeResponse, resp.ContentLength, expectedResponseLength)
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
var responseBytesRead int64
|
|
for {
|
|
if conn.ctx.Err() != nil {
|
|
return conn.ctx.Err()
|
|
}
|
|
|
|
n, err := reader.Read(buf)
|
|
if n > 0 {
|
|
responseBytesRead += int64(n)
|
|
if expectedResponseLength >= 0 && responseBytesRead > expectedResponseLength {
|
|
return fmt.Errorf("%w: response body exceeds expected length %d", errInvalidRangeResponse, expectedResponseLength)
|
|
}
|
|
|
|
data := buf[:n]
|
|
if responseBytesToDiscard > 0 {
|
|
discard := min(int64(len(data)), responseBytesToDiscard)
|
|
data = data[discard:]
|
|
responseBytesToDiscard -= discard
|
|
}
|
|
|
|
finished := false
|
|
var writeOffset int64
|
|
|
|
if len(data) > 0 {
|
|
// 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(len(data)) {
|
|
data = data[:remain]
|
|
finished = true
|
|
}
|
|
}
|
|
writeOffset = conn.Chunk.Begin + conn.Chunk.Downloaded
|
|
f.connMu.Unlock()
|
|
|
|
f.fileMu.Lock()
|
|
if f.file != nil {
|
|
_, writeErr := f.file.WriteAt(data, 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(len(data))
|
|
conn.Downloaded += int64(len(data))
|
|
// 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 {
|
|
if intentionalRestart && resp.ContentLength < 0 {
|
|
if err := f.resizeSequentialResource(responseBytesRead); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if expectedResponseLength >= 0 && responseBytesRead != expectedResponseLength {
|
|
if rangeRequested && responseBytesRead < expectedResponseLength {
|
|
return fmt.Errorf("%w: response body length %d, expected %d", errIncompleteResponse, responseBytesRead, expectedResponseLength)
|
|
}
|
|
return fmt.Errorf("%w: response body length %d, expected %d", errInvalidRangeResponse, responseBytesRead, expectedResponseLength)
|
|
}
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// commitResolveTakeover stops the still-live Resolve download only after the
|
|
// first Range response has been validated. Resolve may have advanced beyond
|
|
// requestedStart while the speculative request was in flight, so the overlap
|
|
// is returned for the caller to discard from the Range response body.
|
|
func (f *Fetcher) commitResolveTakeover(conn *connection, requestedStart int64) (int64, error) {
|
|
if conn.ID != 0 || !f.resolveFallback.CompareAndSwap(true, false) {
|
|
return 0, nil
|
|
}
|
|
|
|
prefetched := f.stopPrefetchAndCopyData()
|
|
if prefetched < requestedStart || prefetched > f.meta.Res.Size {
|
|
return 0, fmt.Errorf("resolve prefetch advanced to invalid offset %d for range start %d", prefetched, requestedStart)
|
|
}
|
|
f.resolveDataPos.Store(prefetched)
|
|
|
|
overlap := prefetched - requestedStart
|
|
f.connMu.Lock()
|
|
if f.ifRange != "" {
|
|
f.rangeValidatorPinned = true
|
|
}
|
|
conn.Chunk.Downloaded = overlap
|
|
conn.Downloaded = prefetched
|
|
f.connMu.Unlock()
|
|
return overlap, nil
|
|
}
|
|
|
|
// completeFromResolveFallback keeps the original full response authoritative
|
|
// when the speculative first Range request cannot take over. It waits for that
|
|
// response to finish, copies it once, and converts the task to one completed
|
|
// sequential connection instead of accepting an incomplete Range result.
|
|
func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
|
|
if conn.ID != 0 || !f.resolveFallback.CompareAndSwap(true, false) {
|
|
return false
|
|
}
|
|
|
|
for !f.prefetchDone.Load() {
|
|
if conn.ctx.Err() != nil {
|
|
return false
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
knownSize := f.meta.Res.Size > 0
|
|
if f.prefetchErr != nil || (knownSize && f.prefetchSize.Load() != f.meta.Res.Size) {
|
|
f.cleanupPrefetchFile()
|
|
return false
|
|
}
|
|
|
|
prefetched := f.stopPrefetchAndCopyData()
|
|
if knownSize && prefetched != f.meta.Res.Size {
|
|
return false
|
|
}
|
|
f.resolveDataPos.Store(prefetched)
|
|
|
|
f.connMu.Lock()
|
|
if !knownSize {
|
|
f.meta.Res.Size = prefetched
|
|
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
|
f.meta.Res.Files[0].Size = prefetched
|
|
}
|
|
}
|
|
f.meta.Res.Range = false
|
|
f.rangeReprobeEligible = false
|
|
f.rangeValidatorPinned = false
|
|
conn.Chunk = newChunk(0, prefetched-1)
|
|
conn.Chunk.Downloaded = prefetched
|
|
conn.Downloaded = prefetched
|
|
conn.Completed = true
|
|
conn.State = connCompleted
|
|
conn.failed = false
|
|
conn.lastErr = nil
|
|
f.connMu.Unlock()
|
|
return true
|
|
}
|
|
|
|
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
|
|
contentRange := resp.Header.Get(base.HttpHeaderContentRange)
|
|
fields := strings.Fields(contentRange)
|
|
if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) {
|
|
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
|
|
rangeAndTotal := strings.Split(fields[1], "/")
|
|
if len(rangeAndTotal) != 2 {
|
|
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
bounds := strings.Split(rangeAndTotal[0], "-")
|
|
if len(bounds) != 2 {
|
|
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
|
|
start, startErr := strconv.ParseInt(bounds[0], 10, 64)
|
|
end, endErr := strconv.ParseInt(bounds[1], 10, 64)
|
|
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
|
|
if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end {
|
|
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) {
|
|
return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end)
|
|
}
|
|
if totalSize > 0 && total != totalSize {
|
|
return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize)
|
|
}
|
|
|
|
expectedLength := end - start + 1
|
|
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
|
|
return 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
|
|
}
|
|
return expectedLength, total, nil
|
|
}
|
|
|
|
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
|
|
if totalSize <= 0 {
|
|
return fmt.Errorf("%w: invalid discovered resource size %d", errInvalidRangeResponse, totalSize)
|
|
}
|
|
|
|
// Publish the discovered extent before the response-ready signal can expand
|
|
// the slow-start batch. Store observes the same metadata/progress snapshot.
|
|
f.connMu.Lock()
|
|
f.fileMu.Lock()
|
|
if f.file == nil {
|
|
f.fileMu.Unlock()
|
|
f.connMu.Unlock()
|
|
return errors.New("target file is not open")
|
|
}
|
|
err := f.file.Truncate(totalSize)
|
|
f.fileMu.Unlock()
|
|
if err != nil {
|
|
f.connMu.Unlock()
|
|
return err
|
|
}
|
|
f.meta.Res.Size = totalSize
|
|
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
|
f.meta.Res.Files[0].Size = totalSize
|
|
}
|
|
conn.Chunk.setEnd(totalSize - 1)
|
|
f.connMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// fallbackToSequentialDownload handles origins that advertise byte ranges but
|
|
// ignore the first Range request and return the full representation with 200.
|
|
// Slow start launches only the primary connection initially, so it is safe to
|
|
// reset the prefetch offset and consume that response from byte zero. If the
|
|
// server changes behavior after parallel connections have started, fail rather
|
|
// than risk assembling a corrupt file.
|
|
func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string) error {
|
|
f.connMu.Lock()
|
|
defer f.connMu.Unlock()
|
|
|
|
if !f.meta.Res.Range {
|
|
return nil
|
|
}
|
|
if conn.ID != 0 || len(f.connections) != 1 {
|
|
return errRangeRequestIgnored
|
|
}
|
|
|
|
f.meta.Res.Range = false
|
|
f.rangeReprobeEligible = true
|
|
f.rangeValidatorPinned = false
|
|
f.ifRange = ifRange
|
|
f.resetConnectionForRestart(conn)
|
|
f.resolveDataPos.Store(0)
|
|
return nil
|
|
}
|
|
|
|
// restartSequentialDownload consumes an If-Range 200 response as the complete
|
|
// current representation. The old prefix is no longer trusted, but this body
|
|
// can safely overwrite it from byte zero.
|
|
func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
|
|
f.connMu.Lock()
|
|
defer f.connMu.Unlock()
|
|
|
|
f.meta.Res.Range = false
|
|
f.rangeReprobeEligible = true
|
|
f.rangeValidatorPinned = false
|
|
f.ifRange = ifRange
|
|
f.resetConnectionForRestart(conn)
|
|
f.resolveDataPos.Store(0)
|
|
}
|
|
|
|
// resizeSequentialResource makes a full restart response authoritative for
|
|
// both progress accounting and the target file. Truncation removes stale tail
|
|
// bytes when the new representation is shorter and preallocates when longer.
|
|
func (f *Fetcher) resizeSequentialResource(size int64) error {
|
|
if size < 0 {
|
|
return fmt.Errorf("invalid sequential response size %d", size)
|
|
}
|
|
|
|
// Keep the file mutation and its recovery metadata in one connMu critical
|
|
// section so Store cannot persist one side of the transition without the
|
|
// other. Other download paths never hold fileMu while acquiring connMu.
|
|
f.connMu.Lock()
|
|
f.fileMu.Lock()
|
|
if f.file == nil {
|
|
f.fileMu.Unlock()
|
|
f.connMu.Unlock()
|
|
return errors.New("target file is not open")
|
|
}
|
|
err := f.file.Truncate(size)
|
|
f.fileMu.Unlock()
|
|
if err != nil {
|
|
f.connMu.Unlock()
|
|
return err
|
|
}
|
|
|
|
f.meta.Res.Size = size
|
|
if len(f.meta.Res.Files) > 0 {
|
|
f.meta.Res.Files[0].Size = size
|
|
}
|
|
f.sequentialSizeUnknown = false
|
|
f.connMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// beginUnknownSizeSequentialResource discards all stale bytes and records that
|
|
// a restarted chunked response has no authoritative total size yet. If this
|
|
// response is interrupted, the persisted flag forces the next attempt to start
|
|
// from byte zero instead of validating a Range response against an old size.
|
|
func (f *Fetcher) beginUnknownSizeSequentialResource() error {
|
|
f.connMu.Lock()
|
|
f.fileMu.Lock()
|
|
if f.file == nil {
|
|
f.fileMu.Unlock()
|
|
f.connMu.Unlock()
|
|
return errors.New("target file is not open")
|
|
}
|
|
err := f.file.Truncate(0)
|
|
f.fileMu.Unlock()
|
|
if err != nil {
|
|
f.connMu.Unlock()
|
|
return err
|
|
}
|
|
|
|
f.meta.Res.Size = 0
|
|
if len(f.meta.Res.Files) > 0 {
|
|
f.meta.Res.Files[0].Size = 0
|
|
}
|
|
f.sequentialSizeUnknown = true
|
|
f.ifRange = ""
|
|
f.rangeValidatorPinned = false
|
|
f.connMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// 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.connMu.Lock()
|
|
writeOffset := 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()
|
|
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)
|
|
f.connMu.Unlock()
|
|
}
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
f.connMu.Lock()
|
|
if f.meta.Res.Size == 0 {
|
|
f.meta.Res.Size = conn.Downloaded
|
|
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
|
f.meta.Res.Files[0].Size = conn.Downloaded
|
|
}
|
|
}
|
|
conn.Completed = true
|
|
conn.State = connCompleted
|
|
f.connMu.Unlock()
|
|
return
|
|
}
|
|
// The Resolve response cannot be resumed on this path. Preserve the read
|
|
// error so an unknown-length stream is not mistaken for a clean EOF.
|
|
f.connMu.Lock()
|
|
conn.State = connFailed
|
|
conn.failed = true
|
|
conn.lastErr = err
|
|
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.connMu.Lock()
|
|
writeOffset := 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()
|
|
|
|
f.connMu.Lock()
|
|
conn.Chunk.Downloaded += int64(n)
|
|
conn.Downloaded += int64(n)
|
|
f.connMu.Unlock()
|
|
}
|
|
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
|
|
}
|
|
|
|
f.connMu.Lock()
|
|
if re := extractRequestError(err); re != nil {
|
|
conn.lastErr = re
|
|
} else {
|
|
conn.lastErr = err
|
|
}
|
|
f.connMu.Unlock()
|
|
|
|
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
|
|
}
|
|
f.connMu.Lock()
|
|
conn.retryTimes++
|
|
f.connMu.Unlock()
|
|
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
|
|
}
|
|
if r.Chunk.openEnded() {
|
|
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
|
|
chunkEnd := *slowestConn.Chunk.End
|
|
helper.Chunk.Begin = chunkEnd - slowestConn.Chunk.remain()/2
|
|
helper.Chunk.setEnd(chunkEnd)
|
|
helper.Chunk.Downloaded = 0
|
|
slowestConn.Chunk.setEnd(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.setEnd(0)
|
|
conn.Chunk.Downloaded = 0
|
|
}
|
|
conn.Downloaded = 0
|
|
conn.Completed = false
|
|
conn.speed = 0
|
|
conn.lastSpeedCheck = 0
|
|
conn.lastSpeedDownload = 0
|
|
}
|
|
|
|
// hasSequentialPrefixLocked reports whether a single sequential connection has
|
|
// a useful contiguous prefix. The caller must hold connMu.
|
|
func (f *Fetcher) hasSequentialPrefixLocked(conn *connection) bool {
|
|
if f.meta == nil || f.meta.Res == nil || f.meta.Res.Range || !f.rangeReprobeEligible || f.meta.Res.Size <= 0 {
|
|
return false
|
|
}
|
|
if conn == nil || conn.ID != 0 || len(f.connections) != 1 || conn.Chunk == nil {
|
|
return false
|
|
}
|
|
downloaded := conn.Chunk.Downloaded
|
|
return downloaded > 0 && downloaded < f.meta.Res.Size
|
|
}
|
|
|
|
// canProbeSequentialResumeLocked reports whether the prefix can be resumed
|
|
// safely with If-Range. The caller must hold connMu.
|
|
func (f *Fetcher) canProbeSequentialResumeLocked(conn *connection) bool {
|
|
return !f.sequentialSizeUnknown && f.ifRange != "" && f.hasSequentialPrefixLocked(conn)
|
|
}
|
|
|
|
func (f *Fetcher) hasSequentialRecoveryCandidate() bool {
|
|
f.connMu.Lock()
|
|
defer f.connMu.Unlock()
|
|
if len(f.connections) != 1 {
|
|
return false
|
|
}
|
|
return f.hasSequentialPrefixLocked(f.connections[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
|
|
}
|
|
}
|
|
if !f.hasSequentialPrefixLocked(conn) {
|
|
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(ctx context.Context) {
|
|
f.wg.Wait()
|
|
// Only trigger completion for the Start cycle that launched this waiter.
|
|
if ctx != nil && 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.openEnded() || 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
|
|
|
|
// Integrity errors must win over byte-count completion. A malformed response
|
|
// may be detected only after the expected bytes have already been written.
|
|
var finalErr error
|
|
for _, conn := range f.connections {
|
|
if conn.State == connFailed && conn.failed && isTerminalRangeError(conn.lastErr) {
|
|
finalErr = fmt.Errorf("connection %d failed: retries=%d, err=%w", conn.ID, conn.retryTimes, conn.lastErr)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check for other errors, but ignore 403 (server connection limit) errors if download completed.
|
|
if finalErr == nil && !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)
|
|
}
|
|
|
|
// If Pause wins before the first Range response is validated, materialize
|
|
// the Resolve prefix before discarding its temporary file. The first
|
|
// connection was allocated after the earlier prefetch snapshot, so advance
|
|
// its range start to the final copied offset for a safe persisted resume.
|
|
if f.meta != nil && f.meta.Res != nil && f.meta.Res.Range && f.prefetchFile != nil {
|
|
f.resolveFallback.Store(false)
|
|
prefetched := f.stopPrefetchAndCopyData()
|
|
if prefetched > 0 {
|
|
f.resolveDataPos.Store(prefetched)
|
|
f.connMu.Lock()
|
|
if len(f.connections) == 1 {
|
|
conn := f.connections[0]
|
|
if conn != nil && conn.ID == 0 && conn.Chunk != nil && !conn.Completed {
|
|
conn.Chunk.Begin = prefetched
|
|
if f.meta.Res.Size > 0 {
|
|
conn.Chunk.setEnd(f.meta.Res.Size - 1)
|
|
} else {
|
|
conn.Chunk.End = nil
|
|
}
|
|
conn.Chunk.Downloaded = 0
|
|
conn.Downloaded = prefetched
|
|
}
|
|
}
|
|
f.connMu.Unlock()
|
|
}
|
|
} else {
|
|
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 {
|
|
err := f.Pause()
|
|
if f.impersonationSession != nil {
|
|
f.impersonationSession.Clear()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
|
return f.meta
|
|
}
|
|
|
|
func (f *Fetcher) Stats() *fetcher.Stats {
|
|
f.connMu.Lock()
|
|
defer f.connMu.Unlock()
|
|
|
|
terminal := f.getState() == stateDone
|
|
prefetched := int64(0)
|
|
connectionCount := len(f.connections)
|
|
if connectionCount == 0 {
|
|
prefetched = f.resolveDataPos.Load()
|
|
if terminal && f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > prefetched {
|
|
// A small range response can finish entirely during resolve prefetch,
|
|
// before a formal connection is created. The fetcher owns this fact, so
|
|
// expose one aggregate connection here instead of making Downloader
|
|
// infer protocol state from task-level progress.
|
|
prefetched = f.meta.Res.Size
|
|
}
|
|
if prefetched > 0 {
|
|
connectionCount = 1
|
|
}
|
|
}
|
|
connectionTotal := f.averageConnectionTotal(connectionCount)
|
|
statsConnections := make([]*fhttp.StatsConnection, 0)
|
|
for _, connection := range f.connections {
|
|
completed := connection.Completed || connection.State == connCompleted
|
|
// A successful fetcher has no active downloads left. Work stealing and
|
|
// completion-by-total-size can leave a helper connection's last internal
|
|
// state non-terminal even though the whole HTTP transfer is finished.
|
|
if terminal && !connection.failed {
|
|
completed = true
|
|
}
|
|
statsConnections = append(statsConnections, &fhttp.StatsConnection{
|
|
Downloaded: connection.Downloaded,
|
|
Total: connectionTotal,
|
|
Completed: completed,
|
|
Failed: connection.failed,
|
|
RetryTimes: connection.retryTimes,
|
|
})
|
|
}
|
|
if len(statsConnections) == 0 && prefetched > 0 {
|
|
statsConnections = append(statsConnections, &fhttp.StatsConnection{
|
|
Downloaded: prefetched,
|
|
Total: connectionTotal,
|
|
Completed: terminal,
|
|
})
|
|
}
|
|
return &fetcher.Stats{
|
|
Snapshot: &fhttp.Stats{
|
|
Connections: statsConnections,
|
|
},
|
|
}
|
|
}
|
|
|
|
// averageConnectionTotal returns the nominal equal share used to compare how
|
|
// much work each currently visible connection has contributed. HTTP slow start
|
|
// can add connections over time, so all lanes are recalculated against the
|
|
// current connection count whenever stats are requested. A remainder is
|
|
// rounded up to avoid reporting 100% before a connection reaches its share.
|
|
func (f *Fetcher) averageConnectionTotal(connectionCount int) int64 {
|
|
if f.meta == nil || f.meta.Res == nil || f.meta.Res.Size <= 0 || connectionCount <= 0 {
|
|
return 0
|
|
}
|
|
total := f.meta.Res.Size / int64(connectionCount)
|
|
if f.meta.Res.Size%int64(connectionCount) != 0 {
|
|
total++
|
|
}
|
|
return total
|
|
}
|
|
|
|
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
|
|
}
|