mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
* fix(http): wait for the prefetch goroutine to exit before reusing its file stopPrefetchAndCopyData waited at most 10 s for the Resolve prefetch, but a stalled body read only times out after readTimeout (15 s). The caller then closed and removed the temporary file while the prefetch goroutine could still write to it. Stopping the prefetch now closes the Resolve body, which interrupts a pending read, and waits on a channel closed when the goroutine exits. Pause uses the same path. The stop channel is closed under resolveRespLock so a concurrent Pause and first-connection takeover cannot close it twice. The proxy test helper now calls Setup before Resolve, as the downloader does, so the Resolve request also goes through the proxy. Fixes #1563 * refactor(http): wait on prefetchExitCh in completeFromResolveFallback Replaces the 10 ms polling loop with the exit channel added in the previous commit, so every place that waits for the prefetch uses it. --------- Co-authored-by: Levi <[email protected]>
2645 lines
74 KiB
Go
2645 lines
74 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"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)
|
|
)
|
|
|
|
// connReadBufferSize is the read buffer each connection allocates once and
|
|
// reuses for its lifetime. 64 KiB keeps per-read overhead low on fast links;
|
|
// at 256 connections it is 16 MiB per task.
|
|
const connReadBufferSize = 64 * 1024
|
|
|
|
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, 16, 256... (capped)
|
|
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
|
|
if s.nextBatchSize == 1 {
|
|
// Bootstrap growth: squaring one would never increase the batch size.
|
|
s.nextBatchSize = min(2, s.maxConnections)
|
|
} else if s.nextBatchSize > s.maxConnections/s.nextBatchSize {
|
|
// Cap before multiplying to avoid integer overflow.
|
|
s.nextBatchSize = s.maxConnections
|
|
} else {
|
|
s.nextBatchSize *= s.nextBatchSize
|
|
}
|
|
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{}
|
|
|
|
// checksumFailed marks that the last completed download failed
|
|
// checksum verification, so the next retry must force a full
|
|
// re-download instead of skipping connections that already
|
|
// reported Completed.
|
|
checksumFailed atomic.Bool
|
|
|
|
// 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
|
|
resolveRespCancel context.CancelFunc // Cancels the Resolve request
|
|
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
|
|
prefetchExitCh chan struct{} // Closed when the prefetch goroutine exits
|
|
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
|
|
// sizeKnown distinguishes a display estimate from an HTTP-confirmed length,
|
|
// including zero. Nil preserves the interpretation of legacy snapshots.
|
|
sizeKnown *bool
|
|
// An interrupted chunked sequential response must restart from byte zero.
|
|
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))
|
|
}
|
|
|
|
// Size state is read under connMu or before download workers are started.
|
|
func (f *Fetcher) hasKnownSize() bool {
|
|
if f.sizeKnown != nil {
|
|
return *f.sizeKnown
|
|
}
|
|
return f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > 0 && !f.sequentialSizeUnknown
|
|
}
|
|
|
|
func (f *Fetcher) knownSize() int64 {
|
|
if f.hasKnownSize() {
|
|
return f.meta.Res.Size
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (f *Fetcher) sizeHint() int64 {
|
|
if f.meta.Opts != nil && f.meta.Opts.Size > 0 {
|
|
return f.meta.Opts.Size
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// setResourceSizeLocked publishes the value and its authority in one snapshot.
|
|
func (f *Fetcher) setResourceSizeLocked(size int64, known bool) {
|
|
if !known {
|
|
size = f.sizeHint()
|
|
}
|
|
f.sizeKnown = &known
|
|
f.meta.Res.Size = size
|
|
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
|
f.meta.Res.Files[0].Size = size
|
|
}
|
|
}
|
|
|
|
// 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) error {
|
|
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
|
|
return err
|
|
}
|
|
f.meta.Req = req
|
|
|
|
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)
|
|
// The body outlives Resolve, so the read timeout gets its own cancel func.
|
|
reqCtx, reqCancel := context.WithCancel(context.Background())
|
|
httpReq, err := f.buildRequest(reqCtx, req)
|
|
if err != nil {
|
|
reqCancel()
|
|
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 {
|
|
reqCancel()
|
|
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()
|
|
reqCancel()
|
|
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
|
|
}
|
|
|
|
// A partial response describes one segment, not the whole resource.
|
|
reuseResolveBody := true
|
|
if resp.StatusCode == base.HttpCodePartialContent {
|
|
start, _, total, err := parseRangeResponse(resp)
|
|
if err != nil {
|
|
resp.Body.Close()
|
|
reqCancel()
|
|
f.setState(stateError)
|
|
return err
|
|
}
|
|
res.Size = total
|
|
res.Range = true
|
|
// Prefetch is a contiguous prefix copied at offset zero. A segment
|
|
// starting elsewhere must be fetched again through the Range path.
|
|
reuseResolveBody = start == 0
|
|
} else {
|
|
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
|
|
if contentLength != "" {
|
|
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
|
|
res.Size = size
|
|
}
|
|
}
|
|
}
|
|
// A hint is only a display value, even when the origin advertises Range.
|
|
known := resp.StatusCode == base.HttpCodePartialContent || resp.ContentLength >= 0
|
|
f.sizeKnown = &known
|
|
f.sequentialSizeUnknown = !res.Range && !known
|
|
if !known {
|
|
res.Size = f.sizeHint()
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
file.Name = appendFilenameExtension(file.Name, resp.Header.Get("Content-Type"))
|
|
|
|
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()
|
|
if reuseResolveBody {
|
|
f.resolveResp = resp
|
|
f.resolveRespCancel = reqCancel
|
|
} else {
|
|
resp.Body.Close()
|
|
reqCancel()
|
|
f.prefetchDone.Store(true)
|
|
}
|
|
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 && reuseResolveBody {
|
|
f.prefetchStopCh = make(chan struct{})
|
|
f.prefetchExitCh = 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)
|
|
if f.prefetchExitCh != nil {
|
|
close(f.prefetchExitCh)
|
|
}
|
|
}()
|
|
|
|
// Get the resolve response
|
|
f.resolveRespLock.Lock()
|
|
resp, cancel := f.resolveResp, f.resolveRespCancel
|
|
f.resolveRespLock.Unlock()
|
|
|
|
if resp == nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
// Close the response even if temporary-file setup fails or shutdown
|
|
// rejects a late prefetch. Otherwise the server can remain blocked writing.
|
|
f.resolveRespLock.Lock()
|
|
f.closeResolveRespLocked()
|
|
f.resolveRespLock.Unlock()
|
|
}()
|
|
|
|
// Create temporary file for prefetch data
|
|
tempDir := ""
|
|
if f.ctl != nil && f.ctl.TempDir != "" {
|
|
tempDir = f.ctl.TempDir
|
|
if err := os.MkdirAll(tempDir, 0700); err != nil {
|
|
f.prefetchErr = err
|
|
return
|
|
}
|
|
}
|
|
tmpFile, err := os.CreateTemp(tempDir, "gopeed-prefetch-*")
|
|
if err != nil {
|
|
f.prefetchErr = err
|
|
return
|
|
}
|
|
if f.ctl != nil {
|
|
if err := f.ctl.TempFiles.Track(tmpFile.Name()); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpFile.Name())
|
|
f.prefetchErr = err
|
|
return
|
|
}
|
|
}
|
|
f.prefetchFile = tmpFile
|
|
f.prefetchFilePath = tmpFile.Name()
|
|
|
|
buf := make([]byte, 32*1024) // 32KB buffer
|
|
reader := f.responseReader(resp, cancel)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// stopPrefetch signals the async prefetch to stop and waits until it exits.
|
|
// Closing the resolve body unblocks a pending Read, so a stalled server cannot
|
|
// keep the prefetch goroutine writing to the temporary file after we reuse it.
|
|
func (f *Fetcher) stopPrefetch() {
|
|
if f.prefetchStopCh == nil {
|
|
return
|
|
}
|
|
|
|
// Pause and the first connection's takeover can stop concurrently, so the
|
|
// check-and-close must be under the lock to avoid closing the channel twice.
|
|
f.resolveRespLock.Lock()
|
|
select {
|
|
case <-f.prefetchStopCh:
|
|
// Already closed
|
|
default:
|
|
close(f.prefetchStopCh)
|
|
}
|
|
if f.resolveResp != nil {
|
|
f.resolveResp.Body.Close()
|
|
f.resolveResp = nil
|
|
}
|
|
f.resolveRespLock.Unlock()
|
|
|
|
if f.prefetchExitCh != nil {
|
|
<-f.prefetchExitCh
|
|
}
|
|
}
|
|
|
|
// stopPrefetchAndGetData stops the async prefetch and returns prefetched bytes
|
|
// It also copies prefetched data to the target file
|
|
func (f *Fetcher) stopPrefetchAndCopyData() int64 {
|
|
f.stopPrefetch()
|
|
|
|
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 != "" {
|
|
if f.ctl != nil {
|
|
f.ctl.TempFiles.Remove(f.prefetchFilePath)
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
// prepareFullRedownload resets all fetcher connection and prefetch state
|
|
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
|
|
func (f *Fetcher) prepareFullRedownload() {
|
|
f.connMu.Lock()
|
|
if f.meta.Res.Range {
|
|
f.connections = nil
|
|
f.resolveConn = nil
|
|
f.rangeReprobeEligible = false
|
|
f.rangeValidatorPinned = false
|
|
}
|
|
f.connMu.Unlock()
|
|
|
|
f.cleanupPrefetchFile()
|
|
f.resolveDataPos.Store(0)
|
|
}
|
|
|
|
// Options may be replaced between Resolve and Start when creating a resolved task.
|
|
func (f *Fetcher) initOptions() error {
|
|
if f.meta.Opts == nil {
|
|
f.meta.Opts = &base.Options{}
|
|
}
|
|
opts := f.meta.Opts
|
|
// 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
|
|
}
|
|
}
|
|
if err := extra.Checksum.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) Start() error {
|
|
if err := f.initOptions(); err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
fullRedownload := f.checksumFailed.Swap(false)
|
|
if fullRedownload {
|
|
// Checksum mismatch: the file content itself is wrong even
|
|
// though every connection reported Completed. Truncate the
|
|
// corrupt file so stale bytes cannot linger if the retry is
|
|
// interrupted before every connection re-writes its chunk.
|
|
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
|
|
}
|
|
f.prepareFullRedownload()
|
|
}
|
|
f.connMu.Lock()
|
|
for _, conn := range f.connections {
|
|
// On a normal error, only reset connections that didn't
|
|
// finish. On a checksum mismatch, reset EVERY connection —
|
|
// "Completed" only means the download loop finished, not
|
|
// that the bytes were verified correct.
|
|
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
|
|
if shouldReset {
|
|
if fullRedownload || !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.knownSize())
|
|
} 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 {
|
|
// Read the size once and judge completeness by it alone: Resolve may have
|
|
// written every byte before it sets prefetchDone, and a snapshot that
|
|
// covers the whole file would otherwise finish without copying it.
|
|
prefetched := f.prefetchSize.Load()
|
|
resolveComplete := f.knownSize() > 0 && prefetched >= f.knownSize()
|
|
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 = prefetched
|
|
} 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()
|
|
f.closeResolveRespLocked()
|
|
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.knownSize()
|
|
|
|
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()
|
|
|
|
var err error
|
|
if chkErr := f.verifyChecksum(); chkErr != nil {
|
|
err = chkErr
|
|
f.setState(stateError)
|
|
} else {
|
|
f.setState(stateDone)
|
|
}
|
|
f.doneCh <- err
|
|
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, connReadBufferSize)
|
|
|
|
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.knownSize() - 1
|
|
rangeEnd = &end
|
|
ifRange = f.ifRange
|
|
} else if rangeMode && (f.rangeValidatorPinned || f.resolveFallback.Load()) {
|
|
ifRange = f.ifRange
|
|
}
|
|
f.connMu.Unlock()
|
|
|
|
// The read timeout cancels this request through its own context.
|
|
reqCtx, reqCancel := context.WithCancel(conn.ctx)
|
|
defer reqCancel()
|
|
|
|
httpReq, err := f.buildRequest(reqCtx, 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(reqCtx, 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.knownSize())
|
|
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.knownSize() - 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.hasKnownSize() && 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 := f.responseReader(resp, reqCancel)
|
|
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.knownSize() {
|
|
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
|
|
}
|
|
|
|
select {
|
|
case <-f.prefetchExitCh:
|
|
case <-conn.ctx.Done():
|
|
return false
|
|
}
|
|
knownSize := f.hasKnownSize()
|
|
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.setResourceSizeLocked(prefetched, true)
|
|
}
|
|
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
|
|
}
|
|
|
|
// parseRangeResponse validates a single byte range with a known complete length.
|
|
// Unknown totals are rejected: a segment length cannot stand in for resource size.
|
|
func parseRangeResponse(resp *http.Response) (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, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
|
|
rangeAndTotal := strings.Split(fields[1], "/")
|
|
if len(rangeAndTotal) != 2 {
|
|
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
bounds := strings.Split(rangeAndTotal[0], "-")
|
|
if len(bounds) != 2 {
|
|
return 0, 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, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
|
}
|
|
expectedLength := end - start + 1
|
|
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
|
|
return 0, 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
|
|
}
|
|
return start, end, total, nil
|
|
}
|
|
|
|
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
|
|
start, end, total, err := parseRangeResponse(resp)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
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)
|
|
}
|
|
|
|
return end - start + 1, 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.setResourceSizeLocked(totalSize, true)
|
|
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.setResourceSizeLocked(size, true)
|
|
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.setResourceSizeLocked(0, false)
|
|
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, connReadBufferSize)
|
|
|
|
// Get the resolve response
|
|
f.resolveRespLock.Lock()
|
|
resp, cancel := f.resolveResp, f.resolveRespCancel
|
|
f.resolveResp, f.resolveRespCancel = nil, nil // Take ownership
|
|
f.resolveRespLock.Unlock()
|
|
|
|
if resp == nil {
|
|
// No resolve response available, fall back to normal connection
|
|
f.runConnectionFallback(conn)
|
|
return
|
|
}
|
|
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
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 := f.responseReader(resp, cancel)
|
|
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.hasKnownSize() {
|
|
f.setResourceSizeLocked(conn.Downloaded, true)
|
|
f.sequentialSizeUnknown = false
|
|
}
|
|
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, connReadBufferSize)
|
|
|
|
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()
|
|
if retries > 0 {
|
|
f.resetConnectionForRestart(conn)
|
|
}
|
|
conn.State = connConnecting
|
|
f.connMu.Unlock()
|
|
|
|
// Restored tasks without a reusable Resolve response need the same
|
|
// length handling and restart semantics as other sequential requests.
|
|
err := f.downloadChunkOnce(conn, client, buf)
|
|
|
|
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 if !f.meta.Res.Range {
|
|
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.knownSize() <= 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.knownSize()
|
|
}
|
|
|
|
// 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.hasKnownSize() && 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 && f.hasKnownSize() {
|
|
// 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.hasKnownSize() && 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 && f.hasKnownSize() {
|
|
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 {
|
|
if chkErr := f.verifyChecksum(); chkErr != nil {
|
|
finalErr = chkErr
|
|
f.setState(stateError)
|
|
} else {
|
|
f.setState(stateDone)
|
|
}
|
|
} else {
|
|
f.setState(stateError)
|
|
}
|
|
|
|
select {
|
|
case f.doneCh <- finalErr:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) verifyChecksum() error {
|
|
if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil {
|
|
return nil
|
|
}
|
|
extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra)
|
|
if !ok || extra.Checksum == nil {
|
|
return nil
|
|
}
|
|
chk := extra.Checksum
|
|
if chk.Algorithm == "" && chk.Expected == "" {
|
|
return nil
|
|
}
|
|
var h hash.Hash
|
|
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
|
|
case "md5":
|
|
h = md5.New()
|
|
case "sha1", "sha-1":
|
|
h = sha1.New()
|
|
case "sha256", "sha-256":
|
|
h = sha256.New()
|
|
default:
|
|
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
|
|
}
|
|
|
|
filePath := f.meta.SingleFilepath()
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("open file for checksum verification failed: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
buf := make([]byte, 64*1024)
|
|
if _, err := io.CopyBuffer(h, file, buf); err != nil {
|
|
return fmt.Errorf("calculate checksum failed: %w", err)
|
|
}
|
|
|
|
actual := hex.EncodeToString(h.Sum(nil))
|
|
expected := strings.ToLower(strings.TrimSpace(chk.Expected))
|
|
if !strings.EqualFold(actual, expected) {
|
|
f.checksumFailed.Store(true)
|
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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.hasKnownSize() && 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 {
|
|
if opts != nil && opts.Size > 0 {
|
|
f.connMu.Lock()
|
|
f.meta.Opts.Size = opts.Size
|
|
if f.meta.Res != nil && !f.hasKnownSize() {
|
|
f.setResourceSizeLocked(0, false)
|
|
}
|
|
f.connMu.Unlock()
|
|
}
|
|
// 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
|
|
f.stopPrefetch()
|
|
|
|
// 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()
|
|
|
|
// 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.knownSize() > 0 {
|
|
conn.Chunk.setEnd(f.knownSize() - 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()
|
|
f.closeResolveRespLocked()
|
|
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
|
|
}
|
|
|
|
// Generated output can pause while its producer waits for processing capacity.
|
|
// Its producer owns upstream deadlines; cancellation still closes the response.
|
|
// Otherwise a stalled read cancels the request via cancel, which must belong
|
|
// to the request that produced resp.
|
|
func (f *Fetcher) responseReader(resp *http.Response, cancel context.CancelFunc) io.Reader {
|
|
if f.ctl != nil && f.ctl.ManagedProduction != nil && resp.Request != nil && f.ctl.ManagedProduction(resp.Request.URL.String()) {
|
|
return resp.Body
|
|
}
|
|
return NewTimeoutReader(resp.Body, readTimeout, cancel)
|
|
}
|
|
|
|
// closeResolveRespLocked closes the kept Resolve response, if any, and
|
|
// releases its request context. The caller must hold resolveRespLock.
|
|
func (f *Fetcher) closeResolveRespLocked() {
|
|
if f.resolveResp == nil {
|
|
return
|
|
}
|
|
f.resolveResp.Body.Close()
|
|
f.resolveResp = nil
|
|
if f.resolveRespCancel != nil {
|
|
f.resolveRespCancel()
|
|
f.resolveRespCancel = nil
|
|
}
|
|
}
|