Files
OpenList/internal/net/request.go
yunxuan d403f75da2 fix: prevent panic in unaligned 64-bit atomic (#2637)
* fix(net): prevent panic in unaligned 64-bit atomic on ARMv7

Convert int64 fields written and readingID to atomic.Int64
guarantees 8-byte alignment on 32-bit ARM architectures.

Unaligned 64-bit atomics fault with SIGILL on armv5/armv6
and armv7. Fixes file copy operations.

* fix(atomic): migrate more atomic useage

* fix(alias): improve link handling and add Clone method for Link struct

* fix warn

* fix(mem): simplify memory reservation handling in MemoryGrowCheck

* fix(link): simplify link handling by using Clone method

* fix bug

* fix(hash): typo

Signed-off-by: Yinan Qin <a.elysia@proton.me>

---------

Signed-off-by: Yinan Qin <a.elysia@proton.me>
Co-authored-by: Elysia <a.elysia@proton.me>
Co-authored-by: j2rong4cn <j2rong@qq.com>
2026-06-22 14:37:44 +08:00

644 lines
16 KiB
Go

package net
import (
"context"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
stdpath "path"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
hcache "github.com/OpenListTeam/OpenList/v4/internal/hybrid_cache"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/buffer"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/aws/aws-sdk-go/aws/awsutil"
log "github.com/sirupsen/logrus"
)
// DefaultDownloadPartSize is the default range of bytes to get at a time when
// using Download().
const DefaultDownloadPartSize = utils.MB * 8
// DefaultDownloadConcurrency is the default number of goroutines to spin up
// when using Download().
const DefaultDownloadConcurrency = 2
// DefaultPartBodyMaxRetries is the default number of retries to make when a part fails to download.
const DefaultPartBodyMaxRetries = 3
var DefaultConcurrencyLimit *ConcurrencyLimit
type Downloader struct {
PartSize int
// PartBodyMaxRetries is the number of retry attempts to make for failed part downloads.
PartBodyMaxRetries int
// The number of goroutines to spin up in parallel when sending parts.
// If this is set to zero, the DefaultDownloadConcurrency value will be used.
//
// Concurrency of 1 will download the parts sequentially.
Concurrency int
//RequestParam HttpRequestParams
HttpClient HttpRequestFunc
*ConcurrencyLimit
}
type HttpRequestFunc func(ctx context.Context, params *HttpRequestParams) (*http.Response, error)
func NewDownloader(options ...func(*Downloader)) *Downloader {
d := &Downloader{ //允许不设置的选项
PartBodyMaxRetries: DefaultPartBodyMaxRetries,
ConcurrencyLimit: DefaultConcurrencyLimit,
}
for _, option := range options {
option(d)
}
return d
}
// Download The Downloader makes multi-thread http requests to remote URL, each chunk(except last one) has PartSize,
// cache some data, then return Reader with assembled data
// Supports range, do not support unknown FileSize, and will fail if FileSize is incorrect
// memory usage is at about Concurrency*PartSize, use this wisely
func (d Downloader) Download(ctx context.Context, p *HttpRequestParams) (readCloser io.ReadCloser, err error) {
var finalP HttpRequestParams
awsutil.Copy(&finalP, p)
if finalP.Range.Length < 0 || finalP.Range.Start+finalP.Range.Length > finalP.Size {
finalP.Range.Length = finalP.Size - finalP.Range.Start
}
impl := downloader{params: &finalP, cfg: d, ctx: ctx}
// Ensures we don't need nil checks later on
// 必需的选项
if impl.cfg.Concurrency == 0 {
impl.cfg.Concurrency = DefaultDownloadConcurrency
}
if impl.cfg.PartSize == 0 {
impl.cfg.PartSize = DefaultDownloadPartSize
}
if conf.MinFreeMemory > 0 && impl.cfg.PartSize > int(conf.MaxBlockLimit) {
impl.cfg.PartSize = int(conf.MaxBlockLimit)
}
if impl.cfg.HttpClient == nil {
impl.cfg.HttpClient = DefaultHttpRequestFunc
}
return impl.download()
}
// downloader is the implementation structure used internally by Downloader.
type downloader struct {
ctx context.Context
cancel context.CancelCauseFunc
cfg Downloader
params *HttpRequestParams //http request params
chunkCh chan chunk //chunk chanel
//wg sync.WaitGroup
mu sync.Mutex
nextChunk int //next chunk id
bufMap map[int]*buffer.PipeBuffer
written atomic.Int64 //total bytes of file downloaded from remote
concurrency int //剩余的并发数,递减。到0时停止并发
pos int64
maxPos int64
delayMu sync.Mutex
readingID atomic.Int64 // 正在被读取的id
hc *hcache.HybridCache
}
type ConcurrencyLimit struct {
mu sync.Mutex
Limit uint32
}
var ErrExceedMaxConcurrency = HttpStatusCodeError(http.StatusTooManyRequests)
func (l *ConcurrencyLimit) Acquire() error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.Limit == 0 {
return ErrExceedMaxConcurrency
}
l.Limit--
return nil
}
func (l *ConcurrencyLimit) Release() {
if l == nil {
return
}
l.mu.Lock()
l.Limit++
l.mu.Unlock()
}
// download performs the implementation of the object download across ranged GETs.
func (d *downloader) download() (io.ReadCloser, error) {
if err := d.cfg.ConcurrencyLimit.Acquire(); err != nil {
return nil, err
}
maxPart := 1
if d.params.Range.Length > int64(d.cfg.PartSize) {
maxPart = int((d.params.Range.Length + int64(d.cfg.PartSize) - 1) / int64(d.cfg.PartSize))
}
if maxPart < d.cfg.Concurrency {
d.cfg.Concurrency = maxPart
}
log.Debugf("cfgConcurrency:%d", d.cfg.Concurrency)
if maxPart == 1 {
resp, err := d.cfg.HttpClient(d.ctx, d.params)
if err != nil {
d.cfg.ConcurrencyLimit.Release()
return nil, err
}
closeFunc := resp.Body.Close
resp.Body = utils.NewReadCloser(resp.Body, func() error {
d.mu.Lock()
defer d.mu.Unlock()
var err error
if closeFunc != nil {
d.cfg.ConcurrencyLimit.Release()
err = closeFunc()
closeFunc = nil
}
return err
})
return resp.Body, nil
}
d.ctx, d.cancel = context.WithCancelCause(d.ctx)
// workers
d.chunkCh = make(chan chunk, d.cfg.Concurrency)
d.pos = d.params.Range.Start
d.maxPos = d.params.Range.Start + d.params.Range.Length
d.concurrency = d.cfg.Concurrency
var err error
d.hc, err = hcache.NewHybridCache(uint64(d.cfg.PartSize), uint64(d.params.Range.Length))
if err == nil {
d.bufMap = make(map[int]*buffer.PipeBuffer, d.cfg.Concurrency)
err = d.sendChunkTask(true)
}
if err != nil {
d.cancel(err)
d.cfg.ConcurrencyLimit.Release()
return nil, d.interrupt()
}
d.mu.Lock()
defer d.mu.Unlock()
return &multiReadCloser{d: d, curBuf: d.popBuf(0), maxPos: maxPart}, nil
}
func (d *downloader) sendChunkTask(newConcurrency bool) (err error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.pos >= d.maxPos {
return nil
}
if newConcurrency {
if d.concurrency <= 0 {
return nil
}
if d.nextChunk > 0 { // 第一个不检查,因为已经检查过了
if err := d.cfg.ConcurrencyLimit.Acquire(); err != nil {
return err
}
defer func() {
if err != nil {
d.cfg.ConcurrencyLimit.Release()
}
}()
}
}
br := d.bufMap[d.nextChunk]
if br == nil {
var b buffer.Block
b, err = d.hc.NextBlock()
if err != nil {
return err
}
br = buffer.NewPipeBuffer(d.ctx, b)
d.bufMap[d.nextChunk] = br
}
finalSize := int64(d.cfg.PartSize)
switch d.nextChunk {
case 0:
// 最小分片在前面有助视频播放?
firstSize := d.params.Range.Length % finalSize
if firstSize > 0 {
minSize := finalSize / 2
// 最小分片太小就调整到一半
finalSize = max(firstSize, minSize)
}
case 1:
firstSize := d.params.Range.Length % finalSize
minSize := finalSize / 2
if firstSize > 0 && firstSize < minSize {
finalSize += firstSize - minSize
}
}
err = br.Reset(int(finalSize))
if err != nil {
return err // 分片算法错误或者下载中断
}
if newConcurrency {
go d.downloadPart()
d.concurrency--
}
ch := chunk{
start: d.pos,
size: finalSize,
id: d.nextChunk,
buf: br,
newConcurrency: newConcurrency,
}
d.pos += finalSize
d.nextChunk++
select {
case <-d.ctx.Done():
return context.Cause(d.ctx)
case d.chunkCh <- ch:
return nil
}
}
// when the final reader Close, we interrupt
func (d *downloader) interrupt() error {
err := context.Cause(d.ctx)
if err == nil {
if d.written.Load() != d.params.Range.Length {
err = fmt.Errorf("interrupted")
}
} else if errors.Is(err, context.Canceled) {
err = nil
}
d.cancel(err)
d.mu.Lock()
defer d.mu.Unlock()
if d.bufMap != nil {
for _, buf := range d.bufMap {
_ = buf.Close()
}
d.bufMap = nil
}
if d.hc != nil {
_ = d.hc.Close()
d.hc = nil
}
if d.maxPos != 0 {
d.maxPos = 0
close(d.chunkCh)
if d.concurrency > 0 {
d.concurrency = -d.concurrency
}
log.Debugf("maxConcurrency:%d", d.cfg.Concurrency+d.concurrency)
}
return err
}
func (d *downloader) popBuf(id int) *buffer.PipeBuffer {
br := d.bufMap[id]
if br != nil {
delete(d.bufMap, id)
d.bufMap[-1] = br // -1 保存最后一次取出的buf用来关闭
}
return br
}
func (d *downloader) finishBuf(nextId int, prev *buffer.PipeBuffer) (next *buffer.PipeBuffer) {
d.readingID.Store(int64(nextId))
d.mu.Lock()
shouldSendTask := d.bufMap[d.nextChunk] == nil
if shouldSendTask {
d.bufMap[d.nextChunk] = prev
}
d.mu.Unlock()
if shouldSendTask {
_ = d.sendChunkTask(false)
} else {
_ = prev.Close()
}
d.mu.Lock()
defer d.mu.Unlock()
return d.popBuf(nextId)
}
// downloadPart is an individual goroutine worker reading from the ch channel
// and performing Http request on the data with a given byte range.
func (d *downloader) downloadPart() {
defer d.cfg.ConcurrencyLimit.Release()
for {
select {
case <-d.ctx.Done():
return
case c, ok := <-d.chunkCh:
if !ok {
return
}
if !d.downloadChunk(&c) {
return
}
}
}
}
// downloadChunk downloads the chunk
func (d *downloader) downloadChunk(ch *chunk) bool {
log.Debugf("start chunk_%d, %+v", ch.id, ch)
params := d.getParamsFromChunk(ch)
var err error
for retry := 0; retry <= d.cfg.PartBodyMaxRetries; retry++ {
var n int64
n, err = d.tryDownloadChunk(params, ch)
if err == nil {
d.incrWritten(n)
log.Debugf("chunk_%d downloaded", ch.id)
return true
}
if d.ctx.Err() != nil {
return false
}
// Check if the returned error is an errNeedRetry.
// If this occurs we unwrap the err to set the underlying error
// and attempt any remaining retries.
if e, ok := err.(*errNeedRetry); ok {
err = e.Unwrap()
if n > 0 {
// 测试:下载时 断开openlist向云盘发起的下载连接
// 校验:下载完后校验文件哈希值 一致
d.incrWritten(n)
ch.start += n
ch.size -= n
params.Range.Start = ch.start
params.Range.Length = ch.size
}
log.Warnf("err chunk_%d, object part download error %s, retrying attempt %d. %v",
ch.id, params.URL, retry, err)
} else if err == errInfiniteRetry {
retry--
} else if err == errCancelConcurrency {
return false // 取消一个的并发
} else {
break
}
}
if err != nil {
d.cancel(err) // 取消所有的并发
}
return false
}
func (d *downloader) delay(ti time.Duration) bool {
t := time.NewTimer(ti)
select {
case <-d.ctx.Done():
t.Stop()
return false
case <-t.C:
return true
}
}
var errCancelConcurrency = errors.New("")
var errInfiniteRetry = errors.New("")
func (d *downloader) tryDownloadChunk(params *HttpRequestParams, ch *chunk) (int64, error) {
resp, err := d.cfg.HttpClient(d.ctx, params)
if err != nil {
statusCode, ok := errs.UnwrapOrSelf(err).(HttpStatusCodeError)
if !ok {
return 0, err
}
if statusCode == http.StatusRequestedRangeNotSatisfiable {
return 0, err
}
if ch.id == 0 { //第1个任务 有限的重试,超过重试就会结束请求
switch statusCode {
default:
return 0, err
case http.StatusTooManyRequests:
case http.StatusBadGateway:
case http.StatusServiceUnavailable:
case http.StatusGatewayTimeout:
}
if !d.delay(time.Millisecond * time.Duration(rand.Uint32N(300)+200)) {
return 0, errCancelConcurrency
}
return 0, &errNeedRetry{err}
}
// 来到这 说明第1个分片下载 连接成功了
// 后续分片下载出错都当超载处理
log.Debugf("err chunk_%d, try downloading:%v", ch.id, err)
d.mu.Lock()
isCancelConcurrency := ch.newConcurrency
if d.concurrency > 0 { // 取消剩余的并发任务
// 用于计算实际的并发数
d.concurrency = -d.concurrency
isCancelConcurrency = true
}
if isCancelConcurrency {
d.concurrency--
d.mu.Unlock()
select {
case <-d.ctx.Done():
return 0, errCancelConcurrency
case d.chunkCh <- *ch:
return 0, errCancelConcurrency
}
}
d.mu.Unlock()
if int64(ch.id) != d.readingID.Load() { //正在被读取的优先重试
d.delayMu.Lock()
defer d.delayMu.Unlock()
if !d.delay(time.Millisecond * time.Duration(rand.Uint32N(300)+200)) {
return 0, errCancelConcurrency
}
}
return 0, errInfiniteRetry
}
defer resp.Body.Close()
//only check file size on the first task
if ch.id == 0 {
err = d.checkTotalBytes(resp)
if err != nil {
return 0, err
}
}
_ = d.sendChunkTask(true)
n, err := utils.CopyWithBuffer(ch.buf, resp.Body)
if err != nil {
return n, &errNeedRetry{err}
}
if n != ch.size {
err = fmt.Errorf("chunk download size incorrect, expected=%d, got=%d", ch.size, n)
return n, &errNeedRetry{err}
}
return n, nil
}
func (d *downloader) getParamsFromChunk(ch *chunk) *HttpRequestParams {
var params HttpRequestParams
awsutil.Copy(&params, d.params)
// Get the getBuf byte range of data
params.Range = http_range.Range{Start: ch.start, Length: ch.size}
return &params
}
func (d *downloader) checkTotalBytes(resp *http.Response) error {
var err error
totalBytes := int64(-1)
contentRange := resp.Header.Get("Content-Range")
if len(contentRange) == 0 {
// ContentRange is nil when the full file contents is provided, and
// is not chunked. Use ContentLength instead.
if resp.ContentLength > 0 {
totalBytes = resp.ContentLength
}
} else {
// Checking for whether a numbered total exists
// If one does not exist, we will assume the total to be -1, undefined,
// and sequentially download each chunk until hitting a 416 error
totalStr := stdpath.Base(contentRange)
if totalStr != "*" {
var total int64
if total, err = strconv.ParseInt(totalStr, 10, 64); err != nil {
err = fmt.Errorf("failed extracting file size: %s", totalStr)
} else {
totalBytes = total
}
} else {
err = fmt.Errorf("file size unknown: %s", contentRange)
}
}
if totalBytes != d.params.Size && err == nil {
err = fmt.Errorf("expect file size=%d unmatch remote report size=%d, need refresh cache", d.params.Size, totalBytes)
}
return err
}
func (d *downloader) incrWritten(n int64) {
d.written.Add(n)
}
// Chunk represents a single chunk of data to write by the worker routine.
// This structure also implements an io.SectionReader style interface for
// io.WriterAt, effectively making it an io.SectionWriter (which does not
// exist).
type chunk struct {
start int64
size int64
buf *buffer.PipeBuffer
id int
newConcurrency bool
}
func DefaultHttpRequestFunc(ctx context.Context, params *HttpRequestParams) (*http.Response, error) {
header := http_range.ApplyRangeToHttpHeader(params.Range, params.HeaderRef)
return RequestHttp(ctx, "GET", header, params.URL)
}
func GetRangeReaderHttpRequestFunc(rangeReader model.RangeReaderIF) HttpRequestFunc {
return func(ctx context.Context, params *HttpRequestParams) (*http.Response, error) {
rc, err := rangeReader.RangeRead(ctx, params.Range)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusPartialContent,
Status: http.StatusText(http.StatusPartialContent),
Body: rc,
Header: http.Header{
"Content-Range": {params.Range.ContentRange(params.Size)},
},
ContentLength: params.Range.Length,
}, nil
}
}
type HttpRequestParams struct {
URL string
//only want data within this range
Range http_range.Range
HeaderRef http.Header
//total file size
Size int64
}
type errNeedRetry struct {
error
}
func (e *errNeedRetry) Unwrap() error {
return e.error
}
type multiReadCloser struct {
pos int //current reader position, start from 0
maxPos int
curBuf *buffer.PipeBuffer
d *downloader
}
func (mr *multiReadCloser) Read(p []byte) (n int, err error) {
if mr.curBuf == nil {
return 0, io.EOF
}
n, err = mr.curBuf.Read(p)
// log.Debugf("read_%d read current buffer, n=%d ,err=%+v", mr.rPos, n, err)
if err == io.EOF {
log.Debugf("read_%d finished current buffer", mr.pos)
mr.pos++
if mr.pos >= mr.maxPos {
return n, io.EOF
}
mr.curBuf = mr.d.finishBuf(mr.pos, mr.curBuf)
return n, nil
}
return n, err
}
func (mr *multiReadCloser) Close() error {
return mr.d.interrupt()
}