feat: replace gblob with blob http transport (#1407)

This commit is contained in:
Levi authored and GitHub committed 2026-07-14 20:05:33 +08:00
1 parent e2ac8ce64b
commit 9fb3758203
34 files changed
+5303 -3057

No files matched your search

+677
View File
@@ -0,0 +1,677 @@
package blob
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
)
const urlPathPrefix = "/__blob/"
const rangeSourceFailureLimit = 2
// unclaimedSourceTTL bounds how long a session-backed source may keep its
// engine alive without ever being claimed by a download task.
var unclaimedSourceTTL = 10 * time.Minute
var (
ErrInvalidURL = errors.New("invalid blob url")
ErrInvalidOptions = errors.New("invalid blob options")
ErrSourceNotFound = errors.New("blob source not found")
ErrSourceRevoked = errors.New("blob source revoked")
ErrSourceClosed = errors.New("blob source closed")
ErrRangeNotAllowed = errors.New("blob range not allowed")
)
type SessionRef interface {
Retain()
Release()
}
type OpenRequest struct {
Offset int64
End int64
}
type OpenFunc func(ctx context.Context, req OpenRequest) (io.ReadCloser, error)
type CreateOptions struct {
ContentType string
Size int64
Range bool
Session SessionRef
}
type Metadata struct {
ContentType string
Size int64
Range bool
}
type Source struct {
ID string
URL string
ContentType string
mu sync.Mutex
size int64
rangeEnabled bool
revoked bool
taskRefs int
readErr error
rangeFailures int
session SessionRef
open OpenFunc
unclaimed *time.Timer
}
type Registry struct {
dir string
mu sync.RWMutex
sources map[string]*Source
serverMu sync.Mutex
listener net.Listener
server *http.Server
baseURL string
}
func NewRegistry(dir string) *Registry {
return &Registry{
dir: dir,
sources: make(map[string]*Source),
}
}
func (r *Registry) Dir() string {
if r == nil {
return ""
}
return r.dir
}
func (r *Registry) IsURL(raw string) bool {
if r == nil {
return false
}
src, err := r.get(raw)
return err == nil && src != nil
}
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
buf := append([]byte(nil), data...)
return r.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
if req.Offset < 0 || req.Offset > int64(len(buf)) {
return nil, ErrSourceNotFound
}
end := int64(len(buf))
if req.End >= req.Offset && req.End+1 < end {
end = req.End + 1
}
return io.NopCloser(bytes.NewReader(buf[req.Offset:end])), nil
}, &CreateOptions{
ContentType: contentType,
Size: int64(len(buf)),
Range: len(buf) > 0,
})
}
func (r *Registry) CreateOpener(open OpenFunc, opts *CreateOptions) (string, error) {
if r == nil {
return "", ErrSourceNotFound
}
if open == nil {
return "", ErrInvalidOptions
}
if opts == nil {
opts = &CreateOptions{}
}
if opts.Size < 0 {
opts.Size = 0
}
if opts.Range && opts.Size <= 0 {
return "", fmt.Errorf("%w: range requires positive size", ErrInvalidOptions)
}
// Keep server generation selection and source insertion atomic with Close.
r.serverMu.Lock()
baseURL, err := r.ensureServerLocked()
if err != nil {
r.serverMu.Unlock()
return "", err
}
id, err := randomID(18)
if err != nil {
r.serverMu.Unlock()
return "", err
}
if opts.Session != nil {
opts.Session.Retain()
}
srcURL := fmt.Sprintf("%s%s", baseURL, id)
src := &Source{
ID: id,
URL: srcURL,
ContentType: opts.ContentType,
size: opts.Size,
rangeEnabled: opts.Range,
session: opts.Session,
open: open,
}
r.mu.Lock()
r.sources[id] = src
r.mu.Unlock()
r.serverMu.Unlock()
if opts.Session != nil && unclaimedSourceTTL > 0 {
src.mu.Lock()
if !src.revoked {
src.unclaimed = time.AfterFunc(unclaimedSourceTTL, func() {
r.expireUnclaimed(src)
})
}
src.mu.Unlock()
}
return src.URL, nil
}
func (r *Registry) Metadata(raw string) (Metadata, error) {
src, err := r.get(raw)
if err != nil {
return Metadata{}, err
}
src.mu.Lock()
defer src.mu.Unlock()
return Metadata{
ContentType: src.ContentType,
Size: src.size,
Range: src.rangeEnabled,
}, nil
}
// Acquire retains a source for a task. A source remains available until every
// task that acquired it calls Release, unless it is explicitly revoked.
func (r *Registry) Acquire(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
return src.acquireTask()
}
// Release releases a task's reference to a source. Releasing the final task
// reference revokes the source. Sources that have never been acquired remain
// registered until Revoke or Registry.Close is called; session-backed sources
// are also bounded by the unclaimed-source TTL.
func (r *Registry) Release(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
session, last, err := src.releaseTask()
if err != nil {
return err
}
if !last {
return nil
}
r.deleteSource(src)
if session != nil {
session.Release()
}
return nil
}
// SourceError returns the first error encountered while opening or reading a
// source. Invalid and missing sources have no source error. HTTP response write
// errors are deliberately not recorded here.
func (r *Registry) SourceError(raw string) error {
src, err := r.get(raw)
if err != nil {
return nil
}
return src.sourceError()
}
func (r *Registry) Revoke(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
r.removeSource(src)
return nil
}
func (r *Registry) Close() error {
if r == nil {
return nil
}
r.serverMu.Lock()
server := r.server
listener := r.listener
r.server = nil
r.listener = nil
r.baseURL = ""
r.mu.Lock()
sources := make([]*Source, 0, len(r.sources))
for _, src := range r.sources {
sources = append(sources, src)
}
r.sources = make(map[string]*Source)
r.mu.Unlock()
r.serverMu.Unlock()
if server != nil {
_ = server.Close()
}
if listener != nil {
_ = listener.Close()
}
for _, src := range sources {
src.close()
}
return nil
}
// ensureServerLocked returns the current server URL or starts a new server.
// The caller must hold serverMu.
func (r *Registry) ensureServerLocked() (string, error) {
if r.baseURL != "" {
return r.baseURL, nil
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
r.listener = listener
r.baseURL = "http://" + listener.Addr().String() + urlPathPrefix
server := &http.Server{Handler: r}
r.server = server
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
_ = r.Close()
}
}()
return r.baseURL, nil
}
func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := parseRequest(req)
if id == "" {
http.NotFound(w, req)
return
}
src, err := r.getByID(id)
if err != nil {
http.NotFound(w, req)
return
}
meta, open, session := src.acquireOpen()
if open == nil {
http.NotFound(w, req)
return
}
if session != nil {
defer session.Release()
}
if src.sourceError() != nil {
http.Error(w, "blob source unavailable", http.StatusGone)
return
}
start, end, ranged, err := parseRange(req.Header.Get("Range"), meta.Size, meta.Range)
if err != nil {
http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
return
}
if !meta.Range {
start, end, ranged = 0, -1, false
}
reader, err := open(req.Context(), OpenRequest{
Offset: start,
End: end,
})
if err != nil {
if req.Context().Err() == nil || !errors.Is(err, req.Context().Err()) {
src.recordSourceError(err, meta.Range)
}
http.Error(w, "blob source unavailable", http.StatusGone)
return
}
if reader == nil {
err = fmt.Errorf("%w: opener returned a nil reader", ErrSourceClosed)
src.recordSourceError(err, meta.Range)
http.Error(w, "blob source unavailable", http.StatusGone)
return
}
defer reader.Close()
writeHeaders(w, meta, start, end, ranged)
if ranged {
w.WriteHeader(http.StatusPartialContent)
}
limit := int64(-1)
if end >= start {
limit = end - start + 1
} else if meta.Size > 0 {
limit = meta.Size
}
_, readErr, writeErr := copyWithFlush(w, reader, limit)
if readErr != nil {
if ctxErr := req.Context().Err(); ctxErr == nil || !errors.Is(readErr, ctxErr) {
src.recordSourceError(readErr, meta.Range)
}
} else if writeErr == nil && req.Context().Err() == nil {
src.recordSourceSuccess(meta.Range)
}
}
func (s *Source) acquireOpen() (Metadata, OpenFunc, SessionRef) {
s.mu.Lock()
defer s.mu.Unlock()
if s.revoked {
return Metadata{}, nil, nil
}
if s.session != nil {
// The source's own retain guarantees that the session cannot become
// closed while this additional active-reader retain is acquired.
s.session.Retain()
}
return Metadata{
ContentType: s.ContentType,
Size: s.size,
Range: s.rangeEnabled,
}, s.open, s.session
}
func (s *Source) acquireTask() error {
s.mu.Lock()
if s.revoked {
s.mu.Unlock()
return ErrSourceRevoked
}
s.taskRefs++
timer := s.unclaimed
s.unclaimed = nil
s.mu.Unlock()
if timer != nil {
timer.Stop()
}
return nil
}
func (s *Source) releaseTask() (session SessionRef, last bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.revoked {
return nil, false, ErrSourceRevoked
}
if s.taskRefs == 0 {
return nil, false, ErrSourceClosed
}
s.taskRefs--
if s.taskRefs > 0 {
return nil, false, nil
}
s.revoked = true
s.open = nil
session = s.session
s.session = nil
return session, true, nil
}
func (s *Source) sourceError() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.readErr
}
func (s *Source) recordSourceError(err error, ranged bool) {
if err == nil {
return
}
s.mu.Lock()
if s.readErr == nil && ranged {
s.rangeFailures++
if s.rangeFailures < rangeSourceFailureLimit {
s.mu.Unlock()
return
}
}
if s.readErr == nil {
s.readErr = err
}
s.mu.Unlock()
}
func (s *Source) recordSourceSuccess(ranged bool) {
if !ranged {
return
}
s.mu.Lock()
if s.readErr == nil {
s.rangeFailures = 0
}
s.mu.Unlock()
}
func (s *Source) close() {
s.mu.Lock()
alreadyRevoked := s.revoked
s.revoked = true
s.open = nil
session := s.session
s.session = nil
timer := s.unclaimed
s.unclaimed = nil
s.mu.Unlock()
if timer != nil {
timer.Stop()
}
if !alreadyRevoked && session != nil {
session.Release()
}
}
func (s *Source) expireUnclaimed() (SessionRef, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.unclaimed = nil
if s.revoked || s.taskRefs > 0 {
return nil, false
}
s.revoked = true
s.open = nil
session := s.session
s.session = nil
return session, true
}
func (r *Registry) expireUnclaimed(src *Source) {
session, expired := src.expireUnclaimed()
if !expired {
return
}
r.deleteSource(src)
if session != nil {
session.Release()
}
}
func (r *Registry) removeSource(src *Source) {
src.close()
r.deleteSource(src)
}
func (r *Registry) deleteSource(src *Source) {
r.mu.Lock()
if current := r.sources[src.ID]; current == src {
delete(r.sources, src.ID)
}
r.mu.Unlock()
}
func writeHeaders(w http.ResponseWriter, meta Metadata, start, end int64, ranged bool) {
if meta.ContentType != "" {
w.Header().Set("Content-Type", meta.ContentType)
}
if meta.Range {
w.Header().Set("Accept-Ranges", "bytes")
}
if meta.Size > 0 {
if ranged {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, meta.Size))
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
return
}
w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
}
}
func copyWithFlush(w http.ResponseWriter, reader io.Reader, limit int64) (written int64, readErr error, writeErr error) {
if limit >= 0 {
reader = io.LimitReader(reader, limit)
}
buf := make([]byte, 32*1024)
flusher, canFlush := w.(http.Flusher)
for {
nr, er := reader.Read(buf)
if er != nil && !errors.Is(er, io.EOF) {
readErr = er
}
if nr > 0 {
nw, ew := w.Write(buf[:nr])
if nw > 0 {
written += int64(nw)
if canFlush {
flusher.Flush()
}
}
if ew != nil {
return written, readErr, ew
}
if nr != nw {
return written, readErr, io.ErrShortWrite
}
}
if er != nil {
if errors.Is(er, io.EOF) {
if limit >= 0 && written < limit {
return written, io.ErrUnexpectedEOF, nil
}
return written, nil, nil
}
return written, readErr, nil
}
}
}
func parseRange(header string, size int64, rangeEnabled bool) (start int64, end int64, ranged bool, err error) {
if header == "" || !rangeEnabled {
return 0, -1, false, nil
}
if size <= 0 {
return 0, 0, false, ErrRangeNotAllowed
}
if !strings.HasPrefix(header, "bytes=") {
return 0, 0, false, fmt.Errorf("unsupported range")
}
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
if len(parts) != 2 || parts[0] == "" {
return 0, 0, false, fmt.Errorf("unsupported range")
}
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 {
return 0, 0, false, fmt.Errorf("invalid range")
}
if start >= size {
return 0, 0, false, fmt.Errorf("range out of bounds")
}
end = size - 1
if parts[1] != "" {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil || end < start {
return 0, 0, false, fmt.Errorf("invalid range")
}
if end >= size {
end = size - 1
}
}
return start, end, true, nil
}
func (r *Registry) get(raw string) (*Source, error) {
id, ok := r.parseURL(raw)
if !ok {
return nil, ErrInvalidURL
}
src, err := r.getByID(id)
if err != nil {
return nil, err
}
src.mu.Lock()
revoked := src.revoked
src.mu.Unlock()
if revoked {
return nil, ErrSourceRevoked
}
return src, nil
}
func (r *Registry) getByID(id string) (*Source, error) {
r.mu.RLock()
defer r.mu.RUnlock()
src := r.sources[id]
if src == nil {
return nil, ErrSourceNotFound
}
return src, nil
}
func (r *Registry) parseURL(raw string) (id string, ok bool) {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "http" || !strings.HasPrefix(u.Path, urlPathPrefix) {
return "", false
}
r.serverMu.Lock()
baseURL := r.baseURL
r.serverMu.Unlock()
if baseURL == "" || !strings.HasPrefix(raw, baseURL) {
return "", false
}
id = strings.TrimPrefix(u.Path, urlPathPrefix)
if id == "" || path.Base(id) != id {
return "", false
}
return id, true
}
func parseRequest(req *http.Request) string {
id := strings.TrimPrefix(req.URL.Path, urlPathPrefix)
if id == "" || id == req.URL.Path || path.Base(id) != id {
return ""
}
return id
}
func randomID(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+898
View File
@@ -0,0 +1,898 @@
package blob
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
)
type terminalErrorReader struct {
err error
}
func (r *terminalErrorReader) Read([]byte) (int, error) {
return 0, r.err
}
type contextErrorReader struct {
ctx context.Context
}
func (r *contextErrorReader) Read([]byte) (int, error) {
<-r.ctx.Done()
return 0, r.ctx.Err()
}
func (r *contextErrorReader) Close() error { return nil }
type failingResponseWriter struct {
header http.Header
err error
}
func (w *failingResponseWriter) Header() http.Header {
return w.header
}
func (w *failingResponseWriter) Write([]byte) (int, error) {
return 0, w.err
}
func (w *failingResponseWriter) WriteHeader(int) {}
type countingSession struct {
mu sync.Mutex
refs int
zero chan struct{}
zeroOnce sync.Once
}
func newCountingSession() *countingSession {
return &countingSession{zero: make(chan struct{})}
}
func (s *countingSession) Retain() {
s.mu.Lock()
s.refs++
s.mu.Unlock()
}
func (s *countingSession) Release() {
s.mu.Lock()
s.refs--
refs := s.refs
s.mu.Unlock()
if refs == 0 {
s.zeroOnce.Do(func() { close(s.zero) })
}
}
func (s *countingSession) RefCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.refs
}
func setUnclaimedSourceTTLForTest(t *testing.T, ttl time.Duration) {
t.Helper()
previous := unclaimedSourceTTL
unclaimedSourceTTL = ttl
t.Cleanup(func() {
unclaimedSourceTTL = previous
})
}
func TestRegistryBlobHTTPGetAndRange(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateBlob([]byte("hello world"), "text/plain")
if err != nil {
t.Fatal(err)
}
if !registry.IsURL(url) {
t.Fatal("expected registry to recognize created url")
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected GET status: %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Length"); got != "11" {
t.Fatalf("unexpected content length: %q", got)
}
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
t.Fatalf("unexpected accept ranges: %q", got)
}
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
t.Fatalf("unexpected content type: %q", got)
}
if string(body) != "hello world" {
t.Fatalf("unexpected GET body: %q", string(body))
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=6-10")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, err = io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("unexpected range status: %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != "bytes 6-10/11" {
t.Fatalf("unexpected content range: %q", got)
}
if string(body) != "world" {
t.Fatalf("unexpected range body: %q", string(body))
}
}
func TestRegistryEmptyBlob(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateBlob(nil, "application/octet-stream")
if err != nil {
t.Fatal(err)
}
meta, err := registry.Metadata(url)
if err != nil {
t.Fatal(err)
}
if meta.Size != 0 || meta.Range {
t.Fatalf("unexpected empty blob metadata: %#v", meta)
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
t.Fatal(readErr)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %d", resp.StatusCode)
}
if len(body) != 0 {
t.Fatalf("unexpected body: %q", body)
}
if got := resp.Header.Get("Content-Length"); got != "0" {
t.Fatalf("unexpected content length: %q", got)
}
}
func TestRegistryOpenerSizeWithoutRange(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
payload := []byte("hello opener")
url, err := registry.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
if req.Offset != 0 || req.End != -1 {
t.Fatalf("unexpected non-range request: %#v", req)
}
return io.NopCloser(bytes.NewReader(payload)), nil
}, &CreateOptions{
ContentType: "text/plain",
Size: int64(len(payload)),
Range: false,
})
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=6-11")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Length"); got != "12" {
t.Fatalf("unexpected content length: %q", got)
}
if got := resp.Header.Get("Accept-Ranges"); got != "" {
t.Fatalf("unexpected accept ranges: %q", got)
}
if string(body) != string(payload) {
t.Fatalf("unexpected body: %q", string(body))
}
}
func TestRegistryOpenerRangeConcurrentRequests(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
payload := []byte("abcdefghijklmnopqrstuvwxyz")
var mu sync.Mutex
var calls []OpenRequest
url, err := registry.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
mu.Lock()
calls = append(calls, req)
mu.Unlock()
start := int(req.Offset)
end := len(payload)
if req.End >= 0 {
end = int(req.End) + 1
}
if start < 0 || start > end || end > len(payload) {
return nil, errors.New("bad test range")
}
return io.NopCloser(bytes.NewReader(payload[start:end])), nil
}, &CreateOptions{
Size: int64(len(payload)),
Range: true,
})
if err != nil {
t.Fatal(err)
}
ranges := []struct {
header string
want string
}{
{header: "bytes=0-4", want: "abcde"},
{header: "bytes=10-15", want: "klmnop"},
{header: "bytes=20-25", want: "uvwxyz"},
}
var wg sync.WaitGroup
for _, item := range ranges {
item := item
wg.Add(1)
go func() {
defer wg.Done()
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Error(err)
return
}
req.Header.Set("Range", item.header)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Error(err)
return
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
t.Error(err)
return
}
if resp.StatusCode != http.StatusPartialContent {
t.Errorf("unexpected status for %s: %d", item.header, resp.StatusCode)
}
if string(body) != item.want {
t.Errorf("unexpected body for %s: %q", item.header, string(body))
}
}()
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
if len(calls) != len(ranges) {
t.Fatalf("unexpected opener call count: %d", len(calls))
}
seen := map[OpenRequest]bool{}
for _, call := range calls {
seen[call] = true
}
for _, want := range []OpenRequest{{Offset: 0, End: 4}, {Offset: 10, End: 15}, {Offset: 20, End: 25}} {
if !seen[want] {
t.Fatalf("missing opener request %#v, got %#v", want, calls)
}
}
}
func TestRegistryRejectsRangeWithoutSize(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
_, err := registry.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("")), nil
}, &CreateOptions{Range: true})
if !errors.Is(err, ErrInvalidOptions) {
t.Fatalf("expected invalid options error, got %v", err)
}
}
func TestRegistryTracksSourceErrors(t *testing.T) {
t.Run("reader error", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
boom := errors.New("reader failed")
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(io.MultiReader(strings.NewReader("partial"), &terminalErrorReader{err: boom})), nil
}, &CreateOptions{})
if err != nil {
t.Fatal(err)
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
t.Fatalf("the HTTP transport should end normally, got %v", readErr)
}
if string(body) != "partial" {
t.Fatalf("unexpected partial body: %q", body)
}
if got := registry.SourceError(url); !errors.Is(got, boom) {
t.Fatalf("expected source error %v, got %v", boom, got)
}
})
t.Run("short known-size reader", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("short")), nil
}, &CreateOptions{Size: 10})
if err != nil {
t.Fatal(err)
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
if got := registry.SourceError(url); !errors.Is(got, io.ErrUnexpectedEOF) {
t.Fatalf("expected unexpected EOF, got %v", got)
}
})
t.Run("range reader error becomes terminal", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
boom := errors.New("range reader failed")
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(io.MultiReader(strings.NewReader("partial"), &terminalErrorReader{err: boom})), nil
}, &CreateOptions{Size: 10, Range: true})
if err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < rangeSourceFailureLimit; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=0-9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
}
if got := registry.SourceError(url); !errors.Is(got, boom) {
t.Fatalf("expected range source error %v, got %v", boom, got)
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusGone {
t.Fatalf("expected terminal source status %d, got %d", http.StatusGone, resp.StatusCode)
}
})
t.Run("short range reader", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("short")), nil
}, &CreateOptions{Size: 10, Range: true})
if err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < rangeSourceFailureLimit; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=0-9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
}
if got := registry.SourceError(url); !errors.Is(got, io.ErrUnexpectedEOF) {
t.Fatalf("expected range unexpected EOF, got %v", got)
}
})
t.Run("successful range retry clears transient failure", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
attempt := 0
boom := errors.New("transient range failure")
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
attempt++
if attempt == 1 {
return io.NopCloser(&terminalErrorReader{err: boom}), nil
}
return io.NopCloser(strings.NewReader("0123456789")), nil
}, &CreateOptions{Size: 10, Range: true})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=0-9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
}
if got := registry.SourceError(url); got != nil {
t.Fatalf("successful retry left transient source error: %v", got)
}
})
t.Run("request cancellation is not a source error", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
opened := make(chan struct{})
url, err := registry.CreateOpener(func(ctx context.Context, _ OpenRequest) (io.ReadCloser, error) {
close(opened)
return &contextErrorReader{ctx: ctx}, nil
}, &CreateOptions{})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
resp, _ := http.DefaultClient.Do(req)
if resp != nil {
_ = resp.Body.Close()
}
close(done)
}()
<-opened
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("cancelled request did not finish")
}
if got := registry.SourceError(url); got != nil {
t.Fatalf("request cancellation was recorded as source error: %v", got)
}
})
t.Run("nil reader", func(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return nil, nil
}, &CreateOptions{})
if err != nil {
t.Fatal(err)
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusGone {
t.Fatalf("expected nil reader status %d, got %d", http.StatusGone, resp.StatusCode)
}
if got := registry.SourceError(url); !errors.Is(got, ErrSourceClosed) {
t.Fatalf("expected closed source error, got %v", got)
}
})
}
func TestRegistryDoesNotRecordResponseWriteError(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("payload")), nil
}, &CreateOptions{Size: 7})
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
writerErr := errors.New("client connection closed")
registry.ServeHTTP(&failingResponseWriter{header: make(http.Header), err: writerErr}, req)
if got := registry.SourceError(url); got != nil {
t.Fatalf("response write error was recorded as a source error: %v", got)
}
}
func TestRegistryTaskReferencesShareSource(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateBlob([]byte("shared"), "text/plain")
if err != nil {
t.Fatal(err)
}
if err := registry.Acquire(url); err != nil {
t.Fatal(err)
}
if err := registry.Acquire(url); err != nil {
t.Fatal(err)
}
if err := registry.Release(url); err != nil {
t.Fatal(err)
}
if !registry.IsURL(url) {
t.Fatal("the first task release revoked a source still used by another task")
}
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil || string(body) != "shared" {
t.Fatalf("unexpected shared response body %q, error %v", body, readErr)
}
if err := registry.Release(url); err != nil {
t.Fatal(err)
}
if registry.IsURL(url) {
t.Fatal("the final task release did not revoke the source")
}
}
func TestRegistryUnclaimedSessionSourceExpires(t *testing.T) {
setUnclaimedSourceTTLForTest(t, 25*time.Millisecond)
registry := NewRegistry(t.TempDir())
defer registry.Close()
session := newCountingSession()
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("unused")), nil
}, &CreateOptions{Size: 6, Session: session})
if err != nil {
t.Fatal(err)
}
if got := session.RefCount(); got != 1 {
t.Fatalf("unexpected initial session ref count: %d", got)
}
select {
case <-session.zero:
case <-time.After(2 * time.Second):
t.Fatal("unclaimed source did not expire")
}
if registry.IsURL(url) {
t.Fatal("expired source is still registered")
}
if got := session.RefCount(); got != 0 {
t.Fatalf("expired source retained its session: %d", got)
}
}
func TestRegistryAcquireStopsUnclaimedSourceExpiration(t *testing.T) {
const ttl = 25 * time.Millisecond
setUnclaimedSourceTTLForTest(t, ttl)
registry := NewRegistry(t.TempDir())
defer registry.Close()
session := newCountingSession()
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("claimed")), nil
}, &CreateOptions{Size: 7, Session: session})
if err != nil {
t.Fatal(err)
}
if err := registry.Acquire(url); err != nil {
t.Fatal(err)
}
select {
case <-session.zero:
t.Fatal("claimed source expired")
case <-time.After(4 * ttl):
}
if !registry.IsURL(url) {
t.Fatal("claimed source was removed")
}
if err := registry.Release(url); err != nil {
t.Fatal(err)
}
select {
case <-session.zero:
case <-time.After(2 * time.Second):
t.Fatal("final task release did not release the session")
}
}
func TestRegistryUnclaimedExpirationKeepsActiveReaderSession(t *testing.T) {
setUnclaimedSourceTTLForTest(t, 100*time.Millisecond)
registry := NewRegistry(t.TempDir())
defer registry.Close()
session := newCountingSession()
reader, writer := io.Pipe()
opened := make(chan struct{})
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
close(opened)
return reader, nil
}, &CreateOptions{Size: 7, Session: session})
if err != nil {
t.Fatal(err)
}
type result struct {
body []byte
err error
}
resultCh := make(chan result, 1)
go func() {
resp, err := http.Get(url)
if err != nil {
resultCh <- result{err: err}
return
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
resultCh <- result{body: body, err: err}
}()
select {
case <-opened:
case got := <-resultCh:
t.Fatalf("request ended before the opener ran: body %q, error %v", got.body, got.err)
case <-time.After(2 * time.Second):
t.Fatal("active request did not open")
}
deadline := time.NewTimer(2 * time.Second)
ticker := time.NewTicker(5 * time.Millisecond)
defer deadline.Stop()
defer ticker.Stop()
for registry.IsURL(url) {
select {
case <-ticker.C:
case <-deadline.C:
t.Fatal("unclaimed source did not expire while its reader was active")
}
}
if got := session.RefCount(); got != 1 {
t.Fatalf("expiration released the active-reader session ref: %d", got)
}
if _, err := writer.Write([]byte("payload")); err != nil {
t.Fatal(err)
}
_ = writer.Close()
select {
case got := <-resultCh:
if got.err != nil || string(got.body) != "payload" {
t.Fatalf("active response was interrupted: body %q, error %v", got.body, got.err)
}
case <-time.After(2 * time.Second):
t.Fatal("active response did not finish after source expiration")
}
select {
case <-session.zero:
if got := session.RefCount(); got != 0 {
t.Fatalf("unexpected final session ref count: %d", got)
}
case <-time.After(2 * time.Second):
t.Fatal("active-reader session ref was not released")
}
}
func TestRegistryCloseCancelsUnclaimedExpiration(t *testing.T) {
const ttl = 25 * time.Millisecond
setUnclaimedSourceTTLForTest(t, ttl)
registry := NewRegistry(t.TempDir())
session := newCountingSession()
_, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("unused")), nil
}, &CreateOptions{Size: 6, Session: session})
if err != nil {
t.Fatal(err)
}
if err := registry.Close(); err != nil {
t.Fatal(err)
}
select {
case <-session.zero:
case <-time.After(2 * time.Second):
t.Fatal("registry close did not release the session")
}
<-time.After(4 * ttl)
if got := session.RefCount(); got != 0 {
t.Fatalf("expiration released the session again after close: %d", got)
}
}
func TestRegistryRevokeKeepsActiveReaderSession(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
session := newCountingSession()
reader, writer := io.Pipe()
opened := make(chan struct{})
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
close(opened)
return reader, nil
}, &CreateOptions{Size: 7, Session: session})
if err != nil {
t.Fatal(err)
}
type result struct {
body []byte
err error
}
resultCh := make(chan result, 1)
go func() {
resp, err := http.Get(url)
if err != nil {
resultCh <- result{err: err}
return
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
resultCh <- result{body: body, err: err}
}()
<-opened
if got := session.RefCount(); got != 2 {
t.Fatalf("expected source and active-reader session refs, got %d", got)
}
if err := registry.Revoke(url); err != nil {
t.Fatal(err)
}
if got := session.RefCount(); got != 1 {
t.Fatalf("revoke released the active-reader session ref: %d", got)
}
if _, err := writer.Write([]byte("payload")); err != nil {
t.Fatal(err)
}
_ = writer.Close()
select {
case got := <-resultCh:
if got.err != nil || string(got.body) != "payload" {
t.Fatalf("active response was interrupted: body %q, error %v", got.body, got.err)
}
case <-time.After(2 * time.Second):
t.Fatal("active response did not finish after revoke")
}
select {
case <-session.zero:
if got := session.RefCount(); got != 0 {
t.Fatalf("unexpected final session ref count: %d", got)
}
case <-time.After(2 * time.Second):
t.Fatal("active-reader session ref was not released")
}
}
func TestRegistryHTTPErrorStatuses(t *testing.T) {
registry := NewRegistry(t.TempDir())
defer registry.Close()
url, err := registry.CreateBlob([]byte("secret"), "text/plain")
if err != nil {
t.Fatal(err)
}
badURL := url + "-missing"
resp, err := http.Get(badURL)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unexpected missing source status: %d", resp.StatusCode)
}
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
t.Fatal(err)
}
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("unexpected method status: %d", resp.StatusCode)
}
req, err = http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=99-100")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("unexpected invalid range status: %d", resp.StatusCode)
}
if err := registry.Revoke(url); err != nil {
t.Fatal(err)
}
resp, err = http.Get(url)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unexpected revoked source status: %d", resp.StatusCode)
}
if registry.IsURL(url) {
t.Fatal("revoked source should not be usable")
}
}
-285
View File
@@ -1,285 +0,0 @@
# gblob Protocol
## Background
Gopeed extensions can resolve a download target into something that is not a normal remote URL.
Typical examples are:
- text generated by an extension
- binary data assembled in JavaScript
- a stream produced incrementally inside the extension runtime
Returning a plain `http` or `https` URL is not enough for these cases.
The `gblob` protocol provides an internal object-URL style transport so extensions can hand generated content to the downloader in a browser-like way.
The extension-facing model is intentionally close to the browser:
- `URL.createObjectURL(new Blob(...))`
- `URL.createObjectURL(readableStream)`
- `URL.createObjectURL(openReadable)`
- `URL.revokeObjectURL(url)`
The returned URL looks like:
```text
gblob:<id>
```
`gblob` is a process-local protocol. The source only exists inside the current Gopeed process.
## Design Goals
- make extension-generated data download as a normal task
- support eager `Blob` data, one-shot `ReadableStream` data, and resumable opener functions
- avoid large in-memory buffering by spooling to disk
- keep pause/continue behavior intuitive
- allow an extension to recreate a new `gblob` URL after source expiration
## High-Level Architecture
The implementation has two layers in this directory:
- [`registry.go`](./registry.go): process-local source registry and spool-file manager
- [`fetcher.go`](./fetcher.go): downloader fetcher implementation for the `gblob` scheme
The end-to-end flow is:
1. JavaScript creates a `Blob`, `ReadableStream`, or opener-function object URL.
2. Go registers a `gblob` source and assigns it a unique `gblob:<id>` URL.
3. Source bytes are written into a local spool file.
4. The downloader resolves the `gblob` URL through the `gblob` fetcher.
5. The fetcher copies bytes from the spool file into the final download target.
## Registry
The registry owns all active `gblob` sources for the current process.
Each source tracks:
- source id and `gblob` URL
- local spool-file path
- source type: `blob` or `writable_stream`
- written byte count
- source state: `open`, `closed`, `aborted`, `failed`
- revoke state
- pin count
- optional extension session reference
Key behaviors:
- `Blob` sources are written once and immediately closed.
- `ReadableStream` sources are bridged into the internal writable-source pipeline.
- opener-function sources are opened lazily and can be reopened from an offset.
- readers wait on a per-source notification channel when no new bytes are available yet.
- revocation only prevents future use; cleanup happens when the source is no longer pinned.
## Fetcher
The fetcher treats `gblob` as a local file-backed download source.
### Resolve
`Resolve()` looks up the source in the registry and builds a single-file `Resource`.
Behavior differences by source type:
- `Blob`: resolved size is the current written size
- `ReadableStream` / opener function: resolved size is whatever the extension declared; if unknown, it stays `0` until download completion updates the final size
`Range` is `true` only when the extension provides a resumable opener and marks the resource as ranged.
### Start
`Start()` opens the final target file and reads from the source spool file.
There are two resume modes:
- same `gblob` id: continue from the current target-file size
- different `gblob` id: truncate the target file and restart from byte `0`
The fetcher persists the last started source id so it can distinguish these two cases across pause/continue and task restore.
### Pause / Continue
`Pause()` stops the current copy loop by canceling the reader side.
It does not abort the `gblob` source.
If the source id stays the same, `Continue()` resumes from the existing target-file offset.
This is how normal in-process `Pause -> Continue` works.
### Source Replacement
If an extension replaces `req.url` with a new `gblob:<new-id>` before the next start, the fetcher detects the id change and restarts from scratch.
This behavior is important for recovery flows such as:
- original `gblob` source expired
- extension recreates a new `gblob` URL in `onError`
- task continues with the new source
## Lifecycle Notes
### In-process pause and continue
For the same `gblob` id:
- the partially downloaded output file is kept
- the fetcher resumes from the current file size
- opener-function sources can be reopened from the current offset
For non-reopenable one-shot `ReadableStream` sources:
- a normal in-process pause/continue only works while the same in-memory source is still alive
- they are not suitable for cross-session or reconstructable range resume
### Process restart
`gblob` sources are not persisted across process restarts.
An unfinished `gblob` task is restored without a live source behind its old URL.
The intended recovery pattern is:
1. user clicks continue
2. the old `gblob` source fails
3. extension `onError` runs
4. extension recreates a new `gblob` URL from `req.rawUrl`
5. extension updates `ctx.task.meta.req.url`
6. extension calls `ctx.task.continue()`
Because the new URL uses a different `gblob` id, the fetcher restarts from byte `0`.
## Why `rawUrl` Exists
When an extension resolves a request into `gblob:<id>`, the original site URL would otherwise be lost.
`Request.RawURL` keeps the original creation URL so lifecycle events can still reason about the original target.
Extension event URL matching prefers `req.rawUrl` when present.
This is especially useful for `onStart`, `onError`, and `onDone` on `gblob` tasks.
## Extension Usage
### 1. Create a `Blob` object URL
```js
gopeed.events.onResolve(async function (ctx) {
const blob = new Blob(["hello world"], { type: "text/plain" });
const url = URL.createObjectURL(blob);
ctx.res = {
name: "example",
files: [
{
name: "hello.txt",
size: 11,
req: { url }
}
]
};
});
```
### 2. Create a one-shot `ReadableStream` object URL
```js
gopeed.events.onResolve(async function (ctx) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("line 1\n"));
controller.enqueue(encoder.encode("line 2\n"));
controller.close();
}
});
const url = URL.createObjectURL(stream);
ctx.res = {
name: "stream-example",
files: [
{
name: "stream.txt",
req: { url }
}
]
};
});
```
### 3. Create a resumable opener object URL
```js
gopeed.events.onResolve(async function (ctx) {
const target = "https://example.com/file.bin";
const openReadable = async (offset) => {
const headers = {};
if (offset > 0) {
headers.Range = `bytes=${offset}-`;
}
const response = await fetch(target, { headers });
if (response.status !== 200 || response.status !== 206) {
throw new Error(`unexpected response status: ${response.status}, expected ${expectedStatus}`);
}
if (!response.body) {
throw new Error("empty response body");
}
return response.body;
};
ctx.res = {
name: "range-example",
range: true,
files: [
{
name: "file.bin",
req: { url: URL.createObjectURL(openReadable) }
}
]
};
});
```
Important:
- opener functions must return a `ReadableStream`
- do not return `response.body.getReader()`
- use opener functions for resumable / range-aware sources
- validate HTTP status codes explicitly:
first request usually expects `200`, resumed range requests usually expect `206`
### 4. Recover in `onError`
```js
gopeed.events.onError(async function (ctx) {
const req = ctx.task.meta.req;
req.url = URL.createObjectURL(new Blob(["recovered\n"]));
ctx.task.continue();
});
```
This creates a new `gblob` id, so the downloader restarts from byte `0`.
## Limitations
- `gblob` is not a network protocol. Range support only exists when the extension recreates bytes through a resumable opener.
- unfinished `gblob` sources do not survive process restarts.
- recovery after restart depends on extension logic recreating a new source.
- the current restart behavior is full restart on source-id change, not byte-range recovery across recreated sources.
- one-shot `ReadableStream` tasks with unknown total size report size `0` until completion updates the final size.
## Summary
`gblob` lets Gopeed extensions treat generated content as a first-class download source.
The registry handles source lifetime and disk spooling.
The fetcher turns those sources into normal download tasks with predictable pause/continue behavior.
The most important rule is simple:
- same `gblob` id means resume
- different `gblob` id means restart
And for extension authors:
- use `ReadableStream` for one-shot streaming content
- use opener functions for resumable content
- opener functions must return `ReadableStream`, not a reader
-415
View File
@@ -1,415 +0,0 @@
package gblob
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
type config struct{}
type Fetcher struct {
ctl *controller.Controller
registry *Registry
meta *fetcher.FetcherMeta
doneCh chan error
mu sync.Mutex
cancel context.CancelFunc
runDone chan struct{}
paused bool
closed bool
downloaded atomic.Int64
lastSourceID string
}
type fetcherData struct {
LastSourceID string `json:"lastSourceId"`
}
func (f *Fetcher) Setup(ctl *controller.Controller) {
f.ctl = ctl
if f.meta == nil {
f.meta = &fetcher.FetcherMeta{}
}
f.doneCh = make(chan error, 1)
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
if f.registry == nil {
return ErrSourceNotFound
}
src, err := f.registry.Get(req.URL)
if err != nil {
return err
}
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
name := f.meta.Opts.Name
if name == "" {
name = src.ID
}
snapshot := src.Snapshot()
size := snapshot.Written
if snapshot.DeclaredSize > 0 {
size = snapshot.DeclaredSize
}
if src.Type == SourceTypeWritableStream {
// Writable streams may continue producing bytes after Resolve,
// so treat the total size as unknown until the writer closes.
if snapshot.DeclaredSize == 0 {
size = 0
}
}
f.meta.Res = &base.Resource{
Size: size,
Range: snapshot.Range,
Files: []*base.FileInfo{
{
Name: name,
Path: "",
Size: size,
Req: req,
},
},
}
return nil
}
func (f *Fetcher) Start() error {
if f.registry == nil {
return ErrSourceNotFound
}
currentSourceID, err := ParseURL(f.meta.Req.URL)
if err != nil {
return err
}
f.mu.Lock()
if f.runDone != nil {
f.mu.Unlock()
return nil
}
f.paused = false
runDone := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
f.cancel = cancel
f.runDone = runDone
f.mu.Unlock()
cleanupStartErr := func(err error) error {
f.mu.Lock()
if f.runDone == runDone {
f.runDone = nil
f.cancel = nil
}
f.mu.Unlock()
return err
}
path := f.meta.SingleFilepath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return cleanupStartErr(err)
}
target, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return cleanupStartErr(err)
}
offset := int64(0)
allowResumeAcrossSource := f.meta.Res != nil && f.meta.Res.Range
if f.lastSourceID != "" && f.lastSourceID != currentSourceID && !allowResumeAcrossSource {
if err := target.Truncate(0); err != nil {
target.Close()
return cleanupStartErr(err)
}
if _, err := target.Seek(0, io.SeekStart); err != nil {
target.Close()
return cleanupStartErr(err)
}
} else {
stat, err := target.Stat()
if err != nil {
target.Close()
return cleanupStartErr(err)
}
offset = stat.Size()
if _, err := target.Seek(offset, io.SeekStart); err != nil {
target.Close()
return cleanupStartErr(err)
}
}
f.lastSourceID = currentSourceID
f.downloaded.Store(offset)
sourceOffset := offset
src, err := f.registry.Get(f.meta.Req.URL)
if err != nil {
target.Close()
return cleanupStartErr(err)
}
snapshot := src.Snapshot()
if snapshot.Written == 0 && !snapshot.Started {
if f.meta.Res != nil && f.meta.Res.Range && offset > 0 {
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
target.Close()
return cleanupStartErr(err)
}
sourceOffset = 0
} else if err := f.registry.StartSource(f.meta.Req.URL); err != nil {
target.Close()
return cleanupStartErr(err)
}
src, err = f.registry.Get(f.meta.Req.URL)
if err != nil {
target.Close()
return cleanupStartErr(err)
}
snapshot = src.Snapshot()
}
if f.meta.Res != nil && f.meta.Res.Range && (snapshot.State == SourceStateAborted || snapshot.State == SourceStateFailed) {
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
target.Close()
return cleanupStartErr(err)
}
sourceOffset = 0
}
go f.copyLoop(ctx, target, offset, sourceOffset)
return nil
}
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, targetOffset, sourceOffset int64) {
var finalErr error
defer target.Close()
defer func() {
f.mu.Lock()
runDone := f.runDone
f.runDone = nil
f.cancel = nil
paused := f.paused
closed := f.closed
f.mu.Unlock()
close(runDone)
if closed {
select {
case f.doneCh <- nil:
default:
}
return
}
if paused {
return
}
select {
case f.doneCh <- finalErr:
default:
}
}()
src, err := f.registry.Get(f.meta.Req.URL)
if err != nil {
finalErr = err
return
}
sourceFile, err := os.Open(src.Path)
if err != nil {
finalErr = err
return
}
defer sourceFile.Close()
buf := make([]byte, 32*1024)
for {
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, sourceOffset); err != nil {
if errors.Is(err, context.Canceled) {
return
}
finalErr = err
return
}
snapshot, serr := f.registry.Get(f.meta.Req.URL)
if serr != nil {
finalErr = serr
return
}
current := snapshot.Snapshot()
for sourceOffset < current.Written {
remain := current.Written - sourceOffset
readSize := int64(len(buf))
if remain < readSize {
readSize = remain
}
n, err := sourceFile.ReadAt(buf[:readSize], sourceOffset)
if err != nil && !errors.Is(err, io.EOF) {
finalErr = err
return
}
if n == 0 {
break
}
if _, err := target.Write(buf[:n]); err != nil {
finalErr = err
return
}
sourceOffset += int64(n)
targetOffset += int64(n)
f.downloaded.Store(targetOffset)
}
if current.State == SourceStateClosed && sourceOffset >= current.Written {
if f.meta.Res != nil {
if f.meta.Res.Size > 0 && targetOffset < f.meta.Res.Size {
finalErr = io.ErrUnexpectedEOF
return
}
if f.meta.Res.Size == 0 {
f.meta.Res.Size = targetOffset
}
f.meta.Res.Files[0].Size = f.meta.Res.Size
}
finalErr = nil
return
}
if current.State == SourceStateAborted || current.State == SourceStateFailed {
err := current.Err
if err == nil {
err = ErrSourceAborted
}
finalErr = err
return
}
}
}
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
return nil
}
func (f *Fetcher) Pause() error {
f.mu.Lock()
f.paused = true
cancel := f.cancel
runDone := f.runDone
f.mu.Unlock()
if cancel != nil {
cancel()
}
if runDone != nil {
<-runDone
}
return nil
}
func (f *Fetcher) Close() error {
f.mu.Lock()
f.closed = true
cancel := f.cancel
runDone := f.runDone
f.mu.Unlock()
if cancel != nil {
cancel()
}
if runDone != nil {
<-runDone
}
select {
case f.doneCh <- nil:
default:
}
return nil
}
func (f *Fetcher) Stats() any {
return map[string]any{}
}
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Progress() fetcher.Progress {
return fetcher.Progress{f.downloaded.Load()}
}
func (f *Fetcher) Wait() error {
return <-f.doneCh
}
type FetcherManager struct {
registry *Registry
}
func (fm *FetcherManager) SetRegistry(registry *Registry) {
fm.registry = registry
}
func (fm *FetcherManager) Name() string {
return Scheme
}
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
return []*fetcher.SchemeFilter{
{
Type: fetcher.FilterTypeUrl,
Pattern: strings.ToUpper(Scheme),
},
}
}
func (fm *FetcherManager) Build() fetcher.Fetcher {
return &Fetcher{
registry: fm.registry,
}
}
func (fm *FetcherManager) ParseName(u string) string {
id, err := ParseURL(u)
if err != nil {
return ""
}
return id
}
func (fm *FetcherManager) AutoRename() bool {
return true
}
func (fm *FetcherManager) DefaultConfig() any {
return &config{}
}
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
_f := f.(*Fetcher)
return &fetcherData{
LastSourceID: _f.lastSourceID,
}, nil
}
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
fd := v.(*fetcherData)
return &Fetcher{
registry: fm.registry,
meta: meta,
doneCh: make(chan error, 1),
lastSourceID: fd.LastSourceID,
}
}
}
func (fm *FetcherManager) Close() error {
return nil
}
-122
View File
@@ -1,122 +0,0 @@
package gblob
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
internalfetcher "github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestFetcherClosedSourceBeforeExpectedSizeReturnsError(t *testing.T) {
registry := NewRegistry(t.TempDir())
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
if err != nil {
t.Fatal(err)
}
if err := registry.Write(url, []byte("abc")); err != nil {
t.Fatal(err)
}
if err := registry.CloseSource(url); err != nil {
t.Fatal(err)
}
downloadDir := t.TempDir()
filePath := filepath.Join(downloadDir, "partial.bin")
f := &Fetcher{
registry: registry,
meta: &internalfetcher.FetcherMeta{
Req: &base.Request{URL: url},
Res: &base.Resource{
Size: 10,
Range: true,
Files: []*base.FileInfo{{Name: "partial.bin", Req: &base.Request{URL: url}}},
},
Opts: &base.Options{Path: downloadDir, Name: "partial.bin"},
},
}
f.Setup(controller.NewController())
if err := f.Start(); err != nil {
t.Fatal(err)
}
select {
case err := <-f.doneCh:
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected unexpected EOF, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for fetcher completion")
}
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatal(err)
}
if string(data) != "abc" {
t.Fatalf("unexpected partial file content: %q", string(data))
}
}
func TestFetcherClosedUnknownSizeSourceCompletesSuccessfully(t *testing.T) {
registry := NewRegistry(t.TempDir())
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
if err != nil {
t.Fatal(err)
}
if err := registry.Write(url, []byte("abcdef")); err != nil {
t.Fatal(err)
}
if err := registry.CloseSource(url); err != nil {
t.Fatal(err)
}
downloadDir := t.TempDir()
filePath := filepath.Join(downloadDir, "stream.bin")
f := &Fetcher{
registry: registry,
meta: &internalfetcher.FetcherMeta{
Req: &base.Request{URL: url},
Res: &base.Resource{
Size: 0,
Range: false,
Files: []*base.FileInfo{{Name: "stream.bin", Req: &base.Request{URL: url}}},
},
Opts: &base.Options{Path: downloadDir, Name: "stream.bin"},
},
}
f.Setup(controller.NewController())
if err := f.Start(); err != nil {
t.Fatal(err)
}
select {
case err := <-f.doneCh:
if err != nil {
t.Fatalf("expected nil completion error, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for fetcher completion")
}
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatal(err)
}
if string(data) != "abcdef" {
t.Fatalf("unexpected stream file content: %q", string(data))
}
if got := f.meta.Res.Size; got != int64(len(data)) {
t.Fatalf("unexpected resolved size: got %d want %d", got, len(data))
}
if got := f.meta.Res.Files[0].Size; got != int64(len(data)) {
t.Fatalf("unexpected resolved file size: got %d want %d", got, len(data))
}
}
-617
View File
@@ -1,617 +0,0 @@
package gblob
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
gonanoid "github.com/matoous/go-nanoid/v2"
)
const Scheme = "gblob"
var (
ErrInvalidURL = errors.New("invalid gblob url")
ErrSourceNotFound = errors.New("gblob source not found")
ErrSourceRevoked = errors.New("gblob source revoked")
ErrSourceClosed = errors.New("gblob source closed")
ErrSourceAborted = errors.New("gblob source aborted")
)
type SourceType string
const (
SourceTypeBlob SourceType = "blob"
SourceTypeWritableStream SourceType = "writable_stream"
)
type SourceState string
const (
SourceStateOpen SourceState = "open"
SourceStateClosed SourceState = "closed"
SourceStateAborted SourceState = "aborted"
SourceStateFailed SourceState = "failed"
)
type SessionRef interface {
Retain()
Release()
}
type ResumeFunc func(offset int64) error
type CreateWritableStreamOptions struct {
Session SessionRef
Reopenable bool
}
type Snapshot struct {
Written int64
DeclaredSize int64
Range bool
Started bool
State SourceState
Err error
WaitCh <-chan struct{}
}
type Source struct {
ID string
URL string
Path string
Type SourceType
ContentType string
mu sync.Mutex
file *os.File
written int64
state SourceState
err error
revoked bool
pins int
waitCh chan struct{}
session SessionRef
sessionReleased bool
reopenable bool
rangeEnabled bool
declaredSize int64
started bool
reopen ResumeFunc
}
func (s *Source) Snapshot() Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
return Snapshot{
Written: s.written,
DeclaredSize: s.declaredSize,
Range: s.rangeEnabled,
Started: s.started,
State: s.state,
Err: s.err,
WaitCh: s.waitCh,
}
}
func (s *Source) State() SourceState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
func (s *Source) IsRevoked() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.revoked
}
func (s *Source) pin() {
s.mu.Lock()
defer s.mu.Unlock()
s.pins++
}
func (s *Source) unpin() int {
s.mu.Lock()
defer s.mu.Unlock()
if s.pins > 0 {
s.pins--
}
return s.pins
}
func (s *Source) shouldCleanupLocked() bool {
return s.revoked && s.pins == 0 && s.state != SourceStateOpen
}
func (s *Source) notifyLocked() {
close(s.waitCh)
s.waitCh = make(chan struct{})
}
func (s *Source) releaseSessionLocked() {
if s.session != nil && !s.sessionReleased {
s.session.Release()
s.sessionReleased = true
}
}
func (s *Source) retainSessionLocked() {
if s.session != nil && s.sessionReleased {
s.session.Retain()
s.sessionReleased = false
}
}
func (s *Source) write(data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.revoked {
return ErrSourceRevoked
}
if s.state != SourceStateOpen {
if s.state == SourceStateAborted || s.state == SourceStateFailed {
if s.err != nil {
return s.err
}
return ErrSourceAborted
}
return ErrSourceClosed
}
if len(data) == 0 {
return nil
}
n, err := s.file.Write(data)
if err != nil {
s.state = SourceStateFailed
s.err = err
s.file.Close()
s.file = nil
if !s.rangeEnabled {
s.releaseSessionLocked()
}
s.notifyLocked()
return err
}
s.written += int64(n)
s.notifyLocked()
return nil
}
func (s *Source) close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.state != SourceStateOpen {
return nil
}
s.state = SourceStateClosed
if s.file != nil {
if err := s.file.Close(); err != nil {
s.err = err
}
s.file = nil
}
s.releaseSessionLocked()
s.notifyLocked()
return nil
}
func (s *Source) abort(err error) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.state != SourceStateOpen {
return nil
}
s.state = SourceStateAborted
if err != nil {
s.err = err
}
if s.file != nil {
_ = s.file.Close()
s.file = nil
}
if !s.rangeEnabled {
s.releaseSessionLocked()
}
s.notifyLocked()
return nil
}
func (s *Source) revoke() bool {
s.mu.Lock()
defer s.mu.Unlock()
s.revoked = true
if s.state == SourceStateOpen {
s.state = SourceStateAborted
if s.err == nil {
s.err = ErrSourceRevoked
}
if s.file != nil {
_ = s.file.Close()
s.file = nil
}
s.releaseSessionLocked()
s.notifyLocked()
}
return s.shouldCleanupLocked()
}
func (s *Source) waitForReadable(ctx context.Context, offset int64) error {
for {
snapshot := s.Snapshot()
if offset < snapshot.Written {
return nil
}
switch snapshot.State {
case SourceStateClosed:
return nil
case SourceStateAborted, SourceStateFailed:
if snapshot.Err != nil {
return snapshot.Err
}
return ErrSourceAborted
}
select {
case <-ctx.Done():
return ctx.Err()
case <-snapshot.WaitCh:
}
}
}
type Registry struct {
dir string
mu sync.RWMutex
sources map[string]*Source
}
func (r *Registry) Dir() string {
if r == nil {
return ""
}
return r.dir
}
func NewRegistry(baseDir string) *Registry {
dir := baseDir
if dir == "" {
dir = filepath.Join(os.TempDir(), "gopeed-gblob")
}
return &Registry{
dir: dir,
sources: make(map[string]*Source),
}
}
func BuildURL(id string) string {
return Scheme + ":" + id
}
func ParseURL(raw string) (string, error) {
if !strings.HasPrefix(strings.ToLower(raw), Scheme+":") {
return "", ErrInvalidURL
}
id := raw[len(Scheme)+1:]
if idx := strings.IndexByte(id, '/'); idx >= 0 {
id = id[:idx]
}
if id == "" {
return "", ErrInvalidURL
}
return id, nil
}
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef, reopenable bool, declaredSize int64) (*Source, error) {
if err := os.MkdirAll(r.dir, 0755); err != nil {
return nil, err
}
id, err := gonanoid.New()
if err != nil {
return nil, err
}
filePath := filepath.Join(r.dir, id)
file, err := os.Create(filePath)
if err != nil {
return nil, err
}
if session != nil {
session.Retain()
}
src := &Source{
ID: id,
URL: BuildURL(id),
Path: filePath,
Type: sourceType,
ContentType: contentType,
file: file,
state: SourceStateOpen,
waitCh: make(chan struct{}),
session: session,
reopenable: reopenable,
rangeEnabled: reopenable,
declaredSize: declaredSize,
started: !reopenable,
}
r.mu.Lock()
r.sources[id] = src
r.mu.Unlock()
return src, nil
}
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
src, err := r.createSource(SourceTypeBlob, contentType, nil, false, int64(len(data)))
if err != nil {
return "", err
}
if err := src.write(data); err != nil {
_ = os.Remove(src.Path)
return "", err
}
if err := src.close(); err != nil {
_ = os.Remove(src.Path)
return "", err
}
return src.URL, nil
}
func (r *Registry) CreateWritableStream(opts *CreateWritableStreamOptions) (string, error) {
if opts == nil {
opts = &CreateWritableStreamOptions{}
}
src, err := r.createSource(SourceTypeWritableStream, "", opts.Session, opts.Reopenable, 0)
if err != nil {
return "", err
}
return src.URL, nil
}
func (r *Registry) SetResumeOpener(raw string, reopen ResumeFunc) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
defer src.mu.Unlock()
if !src.reopenable {
return nil
}
src.reopen = reopen
return nil
}
func (r *Registry) SetRange(raw string, enabled bool) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
src.rangeEnabled = enabled
if !enabled && (src.state == SourceStateAborted || src.state == SourceStateFailed) {
src.releaseSessionLocked()
}
src.mu.Unlock()
return nil
}
func (r *Registry) SetSize(raw string, size int64) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
if size < 0 {
size = 0
}
src.declaredSize = size
src.mu.Unlock()
return nil
}
func (r *Registry) get(raw string) (*Source, error) {
id, err := ParseURL(raw)
if err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
src := r.sources[id]
if src == nil {
return nil, ErrSourceNotFound
}
return src, nil
}
func (r *Registry) Get(raw string) (*Source, error) {
return r.get(raw)
}
func (r *Registry) Write(raw string, data []byte) error {
src, err := r.get(raw)
if err != nil {
return err
}
return src.write(data)
}
func (r *Registry) CloseSource(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
if err := src.close(); err != nil {
return err
}
r.cleanupIfNeeded(src)
return nil
}
func (r *Registry) AbortSource(raw string, err error) error {
src, gerr := r.get(raw)
if gerr != nil {
return gerr
}
if err := src.abort(err); err != nil {
return err
}
r.cleanupIfNeeded(src)
return nil
}
func (r *Registry) Revoke(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
if src.revoke() {
r.cleanupIfNeeded(src)
}
return nil
}
func (r *Registry) Pin(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
if src.IsRevoked() {
return ErrSourceRevoked
}
src.pin()
return nil
}
func (r *Registry) Unpin(raw string) {
src, err := r.get(raw)
if err != nil {
return
}
src.unpin()
r.cleanupIfNeeded(src)
}
func (r *Registry) WaitForReadable(ctx context.Context, raw string, offset int64) error {
src, err := r.get(raw)
if err != nil {
return err
}
return src.waitForReadable(ctx, offset)
}
func (r *Registry) Reopen(raw string, offset int64) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
if !src.reopenable || src.reopen == nil {
src.mu.Unlock()
return ErrSourceNotFound
}
if src.revoked {
src.mu.Unlock()
return ErrSourceRevoked
}
if src.file != nil {
_ = src.file.Close()
src.file = nil
}
if err := os.MkdirAll(filepath.Dir(src.Path), 0755); err != nil {
src.mu.Unlock()
return err
}
file, err := os.Create(src.Path)
if err != nil {
src.mu.Unlock()
return err
}
src.file = file
src.written = 0
src.state = SourceStateOpen
src.err = nil
src.started = true
src.retainSessionLocked()
reopen := src.reopen
src.mu.Unlock()
if err := reopen(offset); err != nil {
src.mu.Lock()
if src.file != nil {
_ = src.file.Close()
src.file = nil
}
src.state = SourceStateFailed
src.err = err
src.releaseSessionLocked()
src.notifyLocked()
src.mu.Unlock()
return err
}
return nil
}
func (r *Registry) StartSource(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
if !src.reopenable || src.started {
src.mu.Unlock()
return nil
}
src.mu.Unlock()
return r.Reopen(raw, 0)
}
func (r *Registry) cleanupIfNeeded(src *Source) {
src.mu.Lock()
shouldCleanup := src.shouldCleanupLocked()
file := src.file
if shouldCleanup {
src.releaseSessionLocked()
}
src.mu.Unlock()
if !shouldCleanup {
return
}
r.mu.Lock()
delete(r.sources, src.ID)
r.mu.Unlock()
if file != nil {
_ = file.Close()
}
_ = os.Remove(src.Path)
}
func (r *Registry) Close() error {
r.mu.Lock()
sources := r.sources
r.sources = make(map[string]*Source)
r.mu.Unlock()
var lastErr error
for _, src := range sources {
src.mu.Lock()
if src.file != nil {
if err := src.file.Close(); err != nil {
lastErr = err
}
src.file = nil
}
src.releaseSessionLocked()
src.notifyLocked()
src.mu.Unlock()
if err := os.Remove(src.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
lastErr = err
}
}
if err := os.RemoveAll(r.dir); err != nil && !errors.Is(err, os.ErrNotExist) {
lastErr = err
}
return lastErr
}
-46
View File
@@ -1,46 +0,0 @@
package gblob
import (
"errors"
"testing"
)
func TestRegistry_RevokeOpenWritableStreamAbortsAndCleansUpAfterUnpin(t *testing.T) {
registry := NewRegistry(t.TempDir())
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
if err != nil {
t.Fatal(err)
}
if err := registry.Pin(url); err != nil {
t.Fatal(err)
}
src, err := registry.Get(url)
if err != nil {
t.Fatal(err)
}
if state := src.Snapshot().State; state != SourceStateOpen {
t.Fatalf("expected initial open state, got %s", state)
}
if err := registry.Revoke(url); err != nil {
t.Fatal(err)
}
src, err = registry.Get(url)
if err != nil {
t.Fatal(err)
}
snapshot := src.Snapshot()
if snapshot.State != SourceStateAborted {
t.Fatalf("expected revoked open source to become aborted, got %s", snapshot.State)
}
if !errors.Is(snapshot.Err, ErrSourceRevoked) {
t.Fatalf("expected revoke error, got %v", snapshot.Err)
}
registry.Unpin(url)
if _, err := registry.Get(url); !errors.Is(err, ErrSourceNotFound) {
t.Fatalf("expected source cleanup after unpin, got %v", err)
}
}
+378 -123
View File
@@ -15,10 +15,10 @@ import (
"sync/atomic"
"time"
internalblob "github.com/GopeedLab/gopeed/internal/blob"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
protogblob "github.com/GopeedLab/gopeed/internal/protocol/gblob"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
@@ -112,7 +112,7 @@ type Downloader struct {
claimedExtractions sync.Map
extensions []*Extension
gblob *protogblob.Registry
blob *internalblob.Registry
}
func NewDownloader(cfg *DownloaderConfig) *Downloader {
@@ -144,11 +144,7 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
}
func (d *Downloader) Setup() error {
gblobDir := filepath.Join(d.cfg.StorageDir, "gblob")
if err := os.RemoveAll(gblobDir); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
d.gblob = protogblob.NewRegistry(gblobDir)
d.blob = internalblob.NewRegistry("")
// setup storage
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
@@ -175,9 +171,6 @@ func (d *Downloader) Setup() error {
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
}
if gfm, ok := fm.(*protogblob.FetcherManager); ok {
gfm.SetRegistry(d.gblob)
}
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
sfm.SetStateStore(&protocolStateStore{
storage: d.storage,
@@ -203,10 +196,6 @@ func (d *Downloader) Setup() error {
}
d.assignFetcherManager(task)
initTask(task)
if task.Protocol == protogblob.Scheme && task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
task.Status = base.DownloadStatusError
continue
}
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
task.Status = base.DownloadStatusPause
}
@@ -253,13 +242,18 @@ func (d *Downloader) Setup() error {
if len(d.tasks) > 0 {
for _, task := range d.tasks {
func() {
task.statusLock.Lock()
defer task.statusLock.Unlock()
if task.Status != base.DownloadStatusRunning && !task.Uploading {
// Do not acquire d.lock (via GetTask) while holding
// statusLock; scheduling uses the opposite lock order.
if d.GetTask(task.ID) == nil {
return
}
// check if task is deleted
if d.GetTask(task.ID) == nil || task.fetcher == nil {
task.statusLock.Lock()
if task.Status != base.DownloadStatusRunning && !task.Uploading {
task.statusLock.Unlock()
return
}
if task.fetcher == nil {
task.statusLock.Unlock()
return
}
@@ -281,6 +275,8 @@ func (d *Downloader) Setup() error {
task.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, tick)
task.Progress.Uploaded = currentUploaded
}
task.statusLock.Unlock()
// Listener callbacks may Pause/Continue and acquire statusLock.
d.emit(EventKeyProgress, task)
// store fetcher progress when download/upload data changed
@@ -360,6 +356,12 @@ func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fet
}
// Get proxy config, task request proxy config has higher priority, then use global proxy config
ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
// Registry-backed blob URLs are loopback capability URLs and must never be
// sent through a configured proxy. Only exact, currently registered URLs
// take this path; everything else keeps normal HTTP proxy behavior.
if d.blob != nil && fetcher != nil && fetcher.Meta() != nil && fetcher.Meta().Req != nil && d.blob.IsURL(fetcher.Meta().Req.URL) {
return nil
}
if requestProxy == nil {
return d.cfg.Proxy.ToHandler()
}
@@ -502,11 +504,6 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId
if err != nil {
return
}
if strings.HasPrefix(strings.ToLower(req.URL), protogblob.Scheme+":") {
if err = fetcher.Resolve(req, initOpt); err != nil {
return
}
}
return d.doCreate(fetcher, initOpt)
}
@@ -549,34 +546,40 @@ func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) err
if task == nil {
return ErrTaskNotFound
}
if err := func() error {
task.lock.Lock()
defer task.lock.Unlock()
// Restore fetcher if not loaded
if task.fetcher == nil {
err := func() error {
task.statusLock.Lock()
defer task.statusLock.Unlock()
// Restore fetcher if not loaded
if task.fetcher == nil {
err := func() error {
task.statusLock.Lock()
defer task.statusLock.Unlock()
return d.restoreFetcher(task)
}()
if err != nil {
return d.restoreFetcher(task)
}()
if err != nil {
return err
}
}
// Call the fetcher's Patch method
if err := task.fetcher.Patch(req, opts); err != nil {
return err
}
}
// Call the fetcher's Patch method
if err := task.fetcher.Patch(req, opts); err != nil {
// Update task meta from fetcher and transfer any live Blob lease.
task.Meta = task.fetcher.Meta()
if err := d.syncBlobTaskLease(task); err != nil {
return err
}
return d.saveTask(task)
}(); err != nil {
return err
}
// Update task meta from fetcher
task.Meta = task.fetcher.Meta()
// Save task to storage
if err := d.saveTask(task); err != nil {
return err
}
// Emit progress event to notify listeners
// Emit outside task.lock so listeners may safely call back into Patch/Pause.
d.emit(EventKeyProgress, task)
return nil
@@ -634,6 +637,7 @@ func (d *Downloader) Continue(filter *TaskFilter) (err error) {
}
realContinueTasks := make([]*Task, 0)
pausedTasks := make([]*Task, 0)
func() {
d.lock.Lock()
defer d.lock.Unlock()
@@ -646,12 +650,16 @@ func (d *Downloader) Continue(filter *TaskFilter) (err error) {
pausedCount := 0
for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning {
if err = d.doPause(task); err != nil {
var queued bool
queued, err = d.doPauseForScheduling(task)
if err != nil {
return
}
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
pausedCount++
if queued {
d.waitTasks = append(d.waitTasks, task)
pausedTasks = append(pausedTasks, task)
pausedCount++
}
}
if pausedCount == needPauseCount {
break
@@ -668,6 +676,14 @@ func (d *Downloader) Continue(filter *TaskFilter) (err error) {
}
}
}()
if err != nil {
return
}
// Scheduling pauses run while d.lock is held, but events must not: listeners
// are allowed to call back into Downloader methods that acquire d.lock.
for _, task := range pausedTasks {
d.emit(EventKeyPause, task)
}
for _, task := range realContinueTasks {
if err = d.doStart(task); err != nil {
@@ -811,7 +827,7 @@ func (d *Downloader) Stats(id string) (sr any, err error) {
}
func (d *Downloader) doDelete(task *Task, force bool) (err error) {
defer d.releaseGBlobTask(task)
defer d.releaseBlobTask(task)
err = func() error {
if err := d.storage.Delete(bucketTask, task.ID); err != nil {
return err
@@ -856,8 +872,8 @@ func (d *Downloader) Close() error {
for _, fm := range d.cfg.FetchManagers {
closeArr = append(closeArr, fm.Close)
}
if d.gblob != nil {
closeArr = append(closeArr, d.gblob.Close)
if d.blob != nil {
closeArr = append(closeArr, d.blob.Close)
}
closeArr = append(closeArr, d.storage.Close)
// Make sure all resources are released, if had error, return the last error
@@ -1024,7 +1040,6 @@ func (d *Downloader) watch(task *Task) {
if _, loaded := d.watchedTasks.LoadOrStore(task.ID, true); loaded {
return
}
defer func() {
d.watchedTasks.Delete(task.ID)
}()
@@ -1047,15 +1062,27 @@ func (d *Downloader) watch(task *Task) {
}
}
if task.Status == base.DownloadStatusDone {
if d.taskStatus(task) == base.DownloadStatusDone {
return
}
for {
err := task.fetcher.Wait()
if err != nil {
d.doOnError(task, err)
if task.Status == base.DownloadStatusRunning {
status := d.taskStatus(task)
if status == base.DownloadStatusPause || status == base.DownloadStatusWait {
return
}
var sourceErr error
if d.blob != nil && task.Meta != nil && task.Meta.Req != nil {
sourceErr = d.blob.SourceError(task.Meta.Req.URL)
}
if sourceErr != nil {
d.doOnBlobSourceError(task, sourceErr)
} else {
d.doOnError(task, err)
}
if d.taskStatus(task) == base.DownloadStatusRunning {
continue
}
return
@@ -1065,6 +1092,15 @@ func (d *Downloader) watch(task *Task) {
if task.Meta.Res == nil || d.GetTask(task.ID) == nil {
return
}
if d.blob != nil && task.Meta.Req != nil {
if err := d.blob.SourceError(task.Meta.Req.URL); err != nil {
d.doOnBlobSourceError(task, err)
if d.taskStatus(task) == base.DownloadStatusRunning {
continue
}
return
}
}
task.Progress.Used = task.timer.Used()
if task.Meta.Res.Size == 0 {
@@ -1077,12 +1113,14 @@ func (d *Downloader) watch(task *Task) {
totalSize := task.Meta.Res.Size
task.Progress.Speed = totalSize / used
task.Progress.Downloaded = totalSize
task.updateStatus(base.DownloadStatusDone)
if !d.markTaskDone(task) {
return
}
d.storage.Put(bucketTask, task.ID, task.clone())
d.emit(EventKeyDone, task)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.releaseGBlobTask(task)
d.releaseBlobTask(task)
d.triggerOnDone(task)
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
d.triggerScripts(ScriptEventDownloadDone, task, nil)
@@ -1148,13 +1186,51 @@ func (d *Downloader) watch(task *Task) {
}
func (d *Downloader) doOnError(task *Task, err error) {
d.handleOnError(task, err, false)
}
func (d *Downloader) doOnBlobSourceError(task *Task, err error) {
d.handleOnError(task, err, true)
}
func (d *Downloader) handleOnError(task *Task, err error, resetFetcher bool) {
if task == nil || task.lock == nil {
return
}
task.lock.Lock()
if !d.markTaskError(task) {
task.lock.Unlock()
return
}
d.Logger.Warn().Err(err).Msgf("task download failed, task id: %s", task.ID)
task.updateStatus(base.DownloadStatusError)
oldURL := ""
if task.Meta != nil && task.Meta.Req != nil {
oldURL = task.Meta.Req.URL
}
d.triggerOnError(task, err)
if task.Status == base.DownloadStatusError {
if !d.isResumableGBlobTask(task) {
d.releaseGBlobTask(task)
newURL := ""
if task.Meta != nil && task.Meta.Req != nil {
newURL = task.Meta.Req.URL
}
if oldURL != "" && newURL != oldURL {
// Extensions are allowed to replace an expired URL from onError. Patch the
// existing fetcher through its public API so protocol-owned redirect state
// is reset without teaching the HTTP fetcher about blob URLs.
if task.fetcher != nil {
if patchErr := task.fetcher.Patch(&base.Request{URL: newURL}, nil); patchErr != nil {
d.Logger.Warn().Err(patchErr).Msgf("patch recovered task url failed, task id: %s", task.ID)
}
}
d.releaseBlobTaskURL(task, oldURL)
}
if resetFetcher && d.taskStatus(task) == base.DownloadStatusRunning {
if resetErr := d.resetTaskFetcher(task); resetErr != nil {
d.Logger.Warn().Err(resetErr).Msgf("reset recovered task fetcher failed, task id: %s", task.ID)
}
}
task.lock.Unlock()
if d.taskStatus(task) == base.DownloadStatusError {
d.releaseBlobTask(task)
d.emit(EventKeyError, task, err)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
@@ -1162,13 +1238,43 @@ func (d *Downloader) doOnError(task *Task, err error) {
}
}
func (d *Downloader) resetTaskFetcher(task *Task) error {
if task == nil || task.Meta == nil || task.Meta.Req == nil {
return errors.New("task request is unavailable")
}
fm, err := d.parseFm(task.Meta.Req.URL)
if err != nil {
return err
}
opts := task.Meta.Opts
f := fm.Build()
d.setupFetcher(fm, f)
// A fresh source must never inherit bytes or preallocation from the failed
// source. HTTP opens existing targets without truncating them, so remove the
// old single-file target before installing a fresh fetcher.
if task.Meta.Res != nil && task.Meta.Opts != nil && len(task.Meta.Res.Files) > 0 {
if err := util.SafeRemove(task.Meta.SingleFilepath()); err != nil {
return err
}
}
f.Meta().Req = task.Meta.Req
f.Meta().Opts = opts
task.fetcherManager = fm
task.fetcher = f
task.Meta = f.Meta()
if task.Progress != nil {
task.Progress.Downloaded = 0
task.Progress.Speed = 0
}
return d.storage.Delete(bucketSave, task.ID)
}
func (d *Downloader) restoreTask(task *Task) error {
if task.fetcher == nil {
if err := d.restoreFetcher(task); err != nil {
return err
}
}
go d.watch(task)
return nil
}
@@ -1207,17 +1313,6 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
if err != nil {
return
}
if fm.Name() == protogblob.Scheme && d.gblob != nil {
if err = d.gblob.Pin(f.Meta().Req.URL); err != nil {
return "", err
}
defer func() {
if err != nil {
d.gblob.Unpin(f.Meta().Req.URL)
}
}()
}
task := NewTask()
task.fetcherManager = fm
task.fetcher = f
@@ -1226,6 +1321,14 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
task.Progress = &Progress{}
_, task.Uploading = f.(fetcher.Uploader)
initTask(task)
if err = d.syncBlobTaskLease(task); err != nil {
return "", err
}
defer func() {
if err != nil {
d.releaseBlobTask(task)
}
}()
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return
}
@@ -1247,7 +1350,6 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
err = d.doStart(task)
}()
go d.watch(task)
return
}
@@ -1293,6 +1395,7 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
func (d *Downloader) doStart(task *Task) (err error) {
var isCreate bool
var generation uint64
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
isReturn = true
@@ -1306,6 +1409,8 @@ func (d *Downloader) doStart(task *Task) (err error) {
}
isCreate = task.Status == base.DownloadStatusReady
task.updateStatus(base.DownloadStatusRunning)
task.runGeneration++
generation = task.runGeneration
return
})
@@ -1317,16 +1422,31 @@ func (d *Downloader) doStart(task *Task) (err error) {
return
}
started := false
handler := func() error {
task.lock.Lock()
defer task.lock.Unlock()
if !d.taskIsRunningGeneration(task, generation) {
return nil
}
if task.fetcher == nil {
if err := d.restoreFetcher(task); err != nil {
return err
}
}
d.triggerOnStart(task)
if !d.taskIsRunningGeneration(task, generation) {
return nil
}
if task.fetcher != nil {
task.fetcher.Meta().Req = task.Meta.Req
task.fetcher.Meta().Res = task.Meta.Res
task.fetcher.Meta().Opts = task.Meta.Opts
}
if err := d.syncBlobTaskLease(task); err != nil {
return err
}
if task.Meta.Res == nil {
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
if err != nil {
@@ -1364,19 +1484,31 @@ func (d *Downloader) doStart(task *Task) (err error) {
}
task.Progress.Speed = 0
if !d.taskIsRunningGeneration(task, generation) {
return nil
}
task.timer.Start()
if err := task.fetcher.Start(); err != nil {
return err
}
started = true
if err := d.saveTask(task); err != nil {
return err
}
d.emit(EventKeyStart, task)
return nil
}
go func() {
if err := handler(); err != nil {
d.doOnError(task, err)
if d.taskIsRunningGeneration(task, generation) {
d.doOnError(task, err)
}
return
}
if started {
if d.taskIsRunningGeneration(task, generation) {
d.emit(EventKeyStart, task)
}
go d.watch(task)
}
}()
@@ -1384,51 +1516,137 @@ func (d *Downloader) doStart(task *Task) (err error) {
}
func (d *Downloader) doPause(task *Task) (err error) {
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
generation, isReturn, err := d.preparePause(task)
if err != nil || isReturn {
return err
}
handled, err := d.runPauseHandler(task, generation, true)
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("pause task handle failed, task id: %s", task.ID)
return err
}
if handled {
d.emit(EventKeyPause, task)
}
return nil
}
// doPauseForScheduling physically pauses a running task without emitting while
// the caller holds d.lock, then atomically moves it to the wait queue state.
func (d *Downloader) doPauseForScheduling(task *Task) (bool, error) {
generation, isReturn, err := d.preparePause(task)
if err != nil || isReturn {
return false, err
}
handled, err := d.runPauseHandler(task, generation, true)
if err != nil || !handled {
return false, err
}
task.statusLock.Lock()
queued := task.Status == base.DownloadStatusPause && task.runGeneration == generation
if queued {
task.Status = base.DownloadStatusWait
}
task.statusLock.Unlock()
return queued, nil
}
func (d *Downloader) preparePause(task *Task) (generation uint64, isReturn bool, err error) {
isReturn, err = d.statusMut(task, func() (isReturn bool, err error) {
if task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusDone {
isReturn = true
return
}
task.updateStatus(base.DownloadStatusPause)
task.runGeneration++
generation = task.runGeneration
task.timer.Pause()
return
})
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("pause task failed, task id: %s", task.ID)
return
}
if isReturn {
return
return generation, isReturn, err
}
func (d *Downloader) runPauseHandler(task *Task, generation uint64, pauseFetcher bool) (bool, error) {
task.lock.Lock()
defer task.lock.Unlock()
if !d.taskIsPausedGeneration(task, generation) {
return false, nil
}
handler := func() error {
task.lock.Lock()
defer task.lock.Unlock()
if task.fetcher != nil {
if err := task.fetcher.Pause(); err != nil {
return err
}
if pauseFetcher && task.fetcher != nil {
if err := task.fetcher.Pause(); err != nil {
return false, err
}
if task.fetcherManager != nil && task.fetcher != nil {
if err := d.saveTask(task); err != nil {
return err
}
} else {
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return err
}
}
d.emit(EventKeyPause, task)
return nil
}
go func() {
if err := handler(); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("pause task handle failed, task id: %s", task.ID)
if task.fetcherManager != nil && task.fetcher != nil {
if err := d.saveTask(task); err != nil {
return false, err
}
}()
return
} else {
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return false, err
}
}
return true, nil
}
func (d *Downloader) taskIsRunningGeneration(task *Task, generation uint64) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
return task.Status == base.DownloadStatusRunning && task.runGeneration == generation
}
func (d *Downloader) taskIsPausedGeneration(task *Task, generation uint64) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
return (task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusWait) && task.runGeneration == generation
}
func (d *Downloader) taskStatus(task *Task) base.Status {
if task == nil || task.statusLock == nil {
return ""
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
return task.Status
}
func (d *Downloader) markTaskDone(task *Task) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
if task.Status != base.DownloadStatusRunning {
return false
}
task.updateStatus(base.DownloadStatusDone)
task.runGeneration++
return true
}
func (d *Downloader) markTaskError(task *Task) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
if task.Status != base.DownloadStatusRunning {
return false
}
task.updateStatus(base.DownloadStatusError)
task.runGeneration++
return true
}
// redirect stderr to log file, when panic happened log it
@@ -1845,40 +2063,77 @@ func initTask(task *Task) {
task.statusLock = &sync.Mutex{}
task.lock = &sync.Mutex{}
task.blobRefLock = &sync.Mutex{}
task.speedArr = make([]int64, 0)
task.uploadSpeedArr = make([]int64, 0)
}
func (d *Downloader) unpinGBlobTask(task *Task) {
if d.gblob == nil || task == nil || task.Meta == nil || task.Meta.Req == nil || task.Meta.Req.URL == "" {
func (d *Downloader) releaseBlobTask(task *Task) {
if d.blob == nil || task == nil {
return
}
if task.Protocol != protogblob.Scheme {
return
if task.blobRefLock != nil {
task.blobRefLock.Lock()
raw := task.blobURL
task.blobURL = ""
task.blobRefLock.Unlock()
if raw != "" {
_ = d.blob.Release(raw)
}
}
d.gblob.Unpin(task.Meta.Req.URL)
}
func (d *Downloader) isResumableGBlobTask(task *Task) bool {
if task == nil || task.Meta == nil || task.Meta.Res == nil {
return false
func (d *Downloader) syncBlobTaskLease(task *Task) error {
if d.blob == nil || task == nil || task.Meta == nil || task.Meta.Req == nil {
return nil
}
return task.Protocol == protogblob.Scheme && task.Meta.Res.Range
if task.blobRefLock == nil {
task.blobRefLock = &sync.Mutex{}
}
task.blobRefLock.Lock()
defer task.blobRefLock.Unlock()
terminal := false
if task.statusLock != nil {
task.statusLock.Lock()
terminal = task.Status == base.DownloadStatusDone || task.Status == base.DownloadStatusError
task.statusLock.Unlock()
}
newURL := ""
if !terminal && d.blob.IsURL(task.Meta.Req.URL) {
newURL = task.Meta.Req.URL
}
if task.blobURL == newURL {
return nil
}
if newURL != "" {
if err := d.blob.Acquire(newURL); err != nil {
return err
}
}
oldURL := task.blobURL
task.blobURL = newURL
if oldURL != "" {
_ = d.blob.Release(oldURL)
}
return nil
}
func (d *Downloader) revokeGBlobTask(task *Task) {
if d.gblob == nil || task == nil || task.Meta == nil || task.Meta.Req == nil || task.Meta.Req.URL == "" {
func (d *Downloader) releaseBlobTaskURL(task *Task, raw string) {
if d.blob == nil || task == nil || raw == "" {
return
}
if task.Protocol != protogblob.Scheme {
return
if task.blobRefLock == nil {
task.blobRefLock = &sync.Mutex{}
}
task.blobRefLock.Lock()
claimed := task.blobURL == raw
if claimed {
task.blobURL = ""
}
task.blobRefLock.Unlock()
if claimed {
_ = d.blob.Release(raw)
}
_ = d.gblob.Revoke(task.Meta.Req.URL)
}
func (d *Downloader) releaseGBlobTask(task *Task) {
d.revokeGBlobTask(task)
d.unpinGBlobTask(task)
}
var defaultDownloader = NewDownloader(nil)
+648 -51
View File
@@ -2,18 +2,26 @@ package download
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
gohttp "net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
internalblob "github.com/GopeedLab/gopeed/internal/blob"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
@@ -21,9 +29,135 @@ import (
"github.com/GopeedLab/gopeed/pkg/util"
)
func newTestDownloadOpt() *base.Options {
type generationTestManager struct {
starts atomic.Int32
pauses atomic.Int32
holdOpen bool
resolveStarted chan struct{}
resolveRelease <-chan struct{}
resolveErr error
resolveOnce sync.Once
pauseStarted chan struct{}
pauseRelease <-chan struct{}
pauseOnce sync.Once
}
func TestExtensionTaskControlMethodsOnlyExistOnError(t *testing.T) {
taskType := reflect.TypeOf((*Task)(nil))
if _, ok := taskType.MethodByName("Continue"); ok {
t.Fatal("regular task unexpectedly exposes Continue")
}
if _, ok := taskType.MethodByName("Pause"); ok {
t.Fatal("regular task unexpectedly exposes Pause")
}
errorTaskType := reflect.TypeOf((*ExtensionTask)(nil))
if _, ok := errorTaskType.MethodByName("Continue"); !ok {
t.Fatal("onError task does not expose Continue")
}
if _, ok := errorTaskType.MethodByName("Pause"); ok {
t.Fatal("onError task unexpectedly exposes Pause")
}
for name, contextType := range map[string]reflect.Type{
"onStart": reflect.TypeOf(OnStartContext{}),
"onDone": reflect.TypeOf(OnDoneContext{}),
} {
field, _ := contextType.FieldByName("Task")
if field.Type != taskType {
t.Fatalf("%s unexpectedly injects a control wrapper: %v", name, field.Type)
}
}
errorTaskField, _ := reflect.TypeOf(OnErrorContext{}).FieldByName("Task")
if errorTaskField.Type != errorTaskType {
t.Fatalf("onError does not inject the Continue wrapper: %v", errorTaskField.Type)
}
}
func (m *generationTestManager) Name() string { return "generation" }
func (m *generationTestManager) Filters() []*fetcher.SchemeFilter {
return []*fetcher.SchemeFilter{{Type: fetcher.FilterTypeUrl, Pattern: "generation"}}
}
func (m *generationTestManager) Build() fetcher.Fetcher {
return &generationTestFetcher{manager: m, meta: &fetcher.FetcherMeta{}}
}
func (m *generationTestManager) ParseName(string) string { return "generation.bin" }
func (m *generationTestManager) AutoRename() bool { return false }
func (m *generationTestManager) DefaultConfig() any { return map[string]any{} }
func (m *generationTestManager) Store(fetcher.Fetcher) (any, error) { return nil, nil }
func (m *generationTestManager) Restore() (any, func(*fetcher.FetcherMeta, any) fetcher.Fetcher) {
return nil, func(meta *fetcher.FetcherMeta, _ any) fetcher.Fetcher {
return &generationTestFetcher{manager: m, meta: meta}
}
}
func (m *generationTestManager) Close() error { return nil }
type generationTestFetcher struct {
manager *generationTestManager
meta *fetcher.FetcherMeta
done chan error
}
func (f *generationTestFetcher) Setup(*controller.Controller) {
if f.done == nil {
f.done = make(chan error, 2)
}
}
func (f *generationTestFetcher) Resolve(req *base.Request, opts *base.Options) error {
if f.manager.resolveStarted != nil {
f.manager.resolveOnce.Do(func() { close(f.manager.resolveStarted) })
}
if f.manager.resolveRelease != nil {
<-f.manager.resolveRelease
}
if f.manager.resolveErr != nil {
return f.manager.resolveErr
}
f.meta.Req = req
f.meta.Opts = opts
f.meta.Res = &base.Resource{Files: []*base.FileInfo{{Name: "generation.bin", Size: 1}}}
return nil
}
func (f *generationTestFetcher) Start() error {
f.manager.starts.Add(1)
if !f.manager.holdOpen {
f.done <- nil
}
return nil
}
func (f *generationTestFetcher) Patch(req *base.Request, opts *base.Options) error {
if req != nil && req.URL != "" {
f.meta.Req.URL = req.URL
}
if opts != nil {
f.meta.Opts = opts
}
return nil
}
func (f *generationTestFetcher) Pause() error {
f.manager.pauses.Add(1)
if f.manager.pauseStarted != nil {
f.manager.pauseOnce.Do(func() { close(f.manager.pauseStarted) })
}
if f.manager.pauseRelease != nil {
<-f.manager.pauseRelease
}
return nil
}
func (f *generationTestFetcher) Close() error { return nil }
func (f *generationTestFetcher) Stats() any { return nil }
func (f *generationTestFetcher) Meta() *fetcher.FetcherMeta { return f.meta }
func (f *generationTestFetcher) Progress() fetcher.Progress { return fetcher.Progress{1} }
func (f *generationTestFetcher) Wait() error { return <-f.done }
func newTestDownloadOpt(t *testing.T) *base.Options {
t.Helper()
return newTestDownloadOptAt(t.TempDir())
}
func newTestDownloadOptAt(path string) *base.Options {
return &base.Options{
Path: test.Dir,
Path: path,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
@@ -63,7 +197,7 @@ func TestDownloader_Resolve(t *testing.T) {
}
}
func TestDownloader_UsesStorageDirForGBlobRegistry(t *testing.T) {
func TestDownloader_BlobRegistryDoesNotUseStorageDirForSpooling(t *testing.T) {
storageDir := t.TempDir()
downloader := NewDownloader(&DownloaderConfig{
Storage: NewMemStorage(),
@@ -74,37 +208,486 @@ func TestDownloader_UsesStorageDirForGBlobRegistry(t *testing.T) {
}
defer downloader.Clear()
if downloader.gblob == nil {
t.Fatal("expected gblob registry to be initialized")
if downloader.blob == nil {
t.Fatal("expected blob registry to be initialized")
}
expectedDir := filepath.Join(storageDir, "gblob")
if filepath.Clean(downloader.gblob.Dir()) != filepath.Clean(expectedDir) {
t.Fatalf("expected gblob registry dir under storage dir, got %s want %s", downloader.gblob.Dir(), expectedDir)
if downloader.blob.Dir() != "" {
t.Fatalf("expected blob registry not to use a spool dir, got %s", downloader.blob.Dir())
}
if _, err := os.Stat(filepath.Join(storageDir, "blob")); !os.IsNotExist(err) {
t.Fatalf("expected blob spool dir not to be created, got err=%v", err)
}
}
func TestDownloader_SetupCleansStaleGBlobDir(t *testing.T) {
storageDir := t.TempDir()
gblobDir := filepath.Join(storageDir, "gblob")
if err := os.MkdirAll(gblobDir, 0o755); err != nil {
t.Fatal(err)
}
staleFile := filepath.Join(gblobDir, "stale")
if err := os.WriteFile(staleFile, []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
func TestDownloader_BlobTaskKeepsConfiguredHTTPConnections(t *testing.T) {
downloader := NewDownloader(&DownloaderConfig{
Storage: NewMemStorage(),
StorageDir: storageDir,
StorageDir: t.TempDir(),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
if _, err := os.Stat(staleFile); !os.IsNotExist(err) {
t.Fatalf("expected stale gblob file to be removed on setup, got err=%v", err)
url, err := downloader.blob.CreateBlob([]byte("hello"), "text/plain")
if err != nil {
t.Fatal(err)
}
id, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
Path: t.TempDir(),
Name: "blob-unmarked.txt",
Extra: &http.OptsExtra{Connections: 8},
})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(id)
if task == nil {
t.Fatal("task not found")
}
if task.Protocol != "http" {
t.Fatalf("expected blob task protocol http, got %s", task.Protocol)
}
extra, ok := task.Meta.Opts.Extra.(*http.OptsExtra)
if !ok {
t.Fatalf("expected http extra, got %T", task.Meta.Opts.Extra)
}
if extra.Connections != 8 {
t.Fatalf("expected blob task connections to stay 8, got %d", extra.Connections)
}
waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 5*time.Second)
}
func TestDownloader_ExternalBlobLikePathUsesNormalHTTP(t *testing.T) {
payload := []byte("external blob-like path")
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
if r.URL.Path != "/__blob/file.bin" {
gohttp.NotFound(w, r)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
_, _ = w.Write(payload)
}))
defer server.Close()
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
dir := t.TempDir()
id, err := downloader.CreateDirect(&base.Request{URL: server.URL + "/__blob/file.bin"}, &base.Options{
Path: dir,
Name: "external.bin",
})
if err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 5*time.Second)
got, err := os.ReadFile(filepath.Join(dir, "external.bin"))
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("unexpected external payload %q", string(got))
}
}
func TestDownloader_InternalBlobBypassesGlobalProxy(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
downloader.cfg.DownloaderStoreConfig.Proxy = &base.DownloaderProxyConfig{
Enable: true,
Scheme: "http",
Host: "127.0.0.1:1",
}
payload := []byte("direct blob")
blobURL, err := downloader.blob.CreateBlob(payload, "application/octet-stream")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
id, err := downloader.CreateDirect(&base.Request{URL: blobURL}, &base.Options{
Path: dir,
Name: "direct.bin",
})
if err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 5*time.Second)
got, err := os.ReadFile(filepath.Join(dir, "direct.bin"))
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("unexpected blob payload %q", string(got))
}
}
func TestDownloader_RegistryMissUsesOrdinaryHTTPError(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
liveURL, err := downloader.blob.CreateBlob([]byte("unused"), "text/plain")
if err != nil {
t.Fatal(err)
}
missingURL := liveURL[:len(liveURL)-1] + "x"
if downloader.blob.IsURL(missingURL) {
t.Fatal("test URL unexpectedly matched the Registry")
}
errorCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyError {
select {
case errorCh <- event.Err:
default:
}
}
})
_, err = downloader.CreateDirect(&base.Request{URL: missingURL}, &base.Options{
Path: t.TempDir(),
Name: "missing.bin",
})
if err != nil {
t.Fatalf("Registry miss was rejected before ordinary HTTP handling: %v", err)
}
select {
case got := <-errorCh:
if errors.Is(got, internalblob.ErrSourceNotFound) {
t.Fatalf("Registry miss surfaced Blob error instead of HTTP failure: %v", got)
}
if got == nil || !strings.Contains(got.Error(), "404") {
t.Fatalf("expected ordinary HTTP 404 failure, got %v", got)
}
case <-time.After(10 * time.Second):
t.Fatal("Registry miss did not reach the ordinary HTTP error path")
}
}
func TestDownloader_TasksShareBlobLease(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
downloader.cfg.MaxRunning = 1
payload := bytes.Repeat([]byte("shared-blob-"), 8192)
allowLaterOpens := make(chan struct{})
var opens atomic.Int32
blobURL, err := downloader.blob.CreateOpener(func(ctx context.Context, req internalblob.OpenRequest) (io.ReadCloser, error) {
if opens.Add(1) > 1 {
select {
case <-allowLaterOpens:
case <-ctx.Done():
return nil, ctx.Err()
}
}
start := req.Offset
end := int64(len(payload))
if req.End >= start && req.End+1 < end {
end = req.End + 1
}
return io.NopCloser(bytes.NewReader(payload[start:end])), nil
}, &internalblob.CreateOptions{Size: int64(len(payload))})
if err != nil {
t.Fatal(err)
}
dirA, dirB := t.TempDir(), t.TempDir()
idA, err := downloader.CreateDirect(&base.Request{URL: blobURL}, &base.Options{Path: dirA, Name: "a.bin"})
if err != nil {
t.Fatal(err)
}
idB, err := downloader.CreateDirect(&base.Request{URL: blobURL}, &base.Options{Path: dirB, Name: "b.bin"})
if err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, idA, base.DownloadStatusDone, 5*time.Second)
if err := downloader.Delete(&TaskFilter{IDs: []string{idA}}, false); err != nil {
t.Fatal(err)
}
if !downloader.blob.IsURL(blobURL) {
t.Fatal("deleting the first task revoked a Blob still leased by the second task")
}
close(allowLaterOpens)
waitForTaskStatus(t, downloader, idB, base.DownloadStatusDone, 5*time.Second)
if downloader.blob.IsURL(blobURL) {
t.Fatal("final task completion did not release the shared Blob")
}
for _, file := range []string{filepath.Join(dirA, "a.bin"), filepath.Join(dirB, "b.bin")} {
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("unexpected shared Blob content in %s", file)
}
}
}
func TestDownloader_EmptyBlob(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
blobURL, err := downloader.blob.CreateBlob(nil, "application/octet-stream")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
id, err := downloader.CreateDirect(&base.Request{URL: blobURL}, &base.Options{Path: dir, Name: "empty.bin"})
if err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 5*time.Second)
info, err := os.Stat(filepath.Join(dir, "empty.bin"))
if err != nil {
t.Fatal(err)
}
if info.Size() != 0 {
t.Fatalf("unexpected empty Blob size: %d", info.Size())
}
}
func TestDownloader_PatchTransfersBlobLease(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
// Keep the task waiting so Patch exercises only metadata and lease transfer.
downloader.cfg.MaxRunning = 0
urlA, err := downloader.blob.CreateBlob([]byte("a"), "text/plain")
if err != nil {
t.Fatal(err)
}
urlB, err := downloader.blob.CreateBlob([]byte("b"), "text/plain")
if err != nil {
t.Fatal(err)
}
id, err := downloader.CreateDirect(&base.Request{URL: urlA}, &base.Options{Path: t.TempDir(), Name: "patched.bin"})
if err != nil {
t.Fatal(err)
}
if err := downloader.Patch(id, &base.Request{URL: urlB}, nil); err != nil {
t.Fatal(err)
}
if downloader.blob.IsURL(urlA) {
t.Fatal("Patch retained the old Blob lease")
}
if !downloader.blob.IsURL(urlB) {
t.Fatal("Patch did not acquire the new Blob lease")
}
if err := downloader.Delete(&TaskFilter{IDs: []string{id}}, false); err != nil {
t.Fatal(err)
}
if downloader.blob.IsURL(urlB) {
t.Fatal("deleting patched task did not release the new Blob lease")
}
}
func TestDownloader_StaleStartGenerationCannotRestartTask(t *testing.T) {
manager := &generationTestManager{}
downloader := NewDownloader(&DownloaderConfig{FetchManagers: []fetcher.FetcherManager{manager}})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
downloader.cfg.MaxRunning = 0
id, err := downloader.CreateDirect(&base.Request{URL: "generation://test"}, &base.Options{
Path: t.TempDir(),
Name: "generation.bin",
})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(id)
if task == nil || task.Status != base.DownloadStatusWait {
t.Fatalf("expected queued task, got %#v", task)
}
// Hold task.lock so the first Start, its Pause handler, and the second Start
// are all queued. Only the newest start generation may reach Fetcher.Start.
task.lock.Lock()
downloader.cfg.MaxRunning = 1
if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil {
t.Fatal(err)
}
pauseDone := make(chan error, 1)
go func() {
pauseDone <- downloader.Pause(&TaskFilter{IDs: []string{id}})
}()
deadline := time.Now().Add(2 * time.Second)
for downloader.taskStatus(task) != base.DownloadStatusPause && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if downloader.taskStatus(task) != base.DownloadStatusPause {
t.Fatal("Pause did not update task status")
}
if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil {
t.Fatal(err)
}
task.lock.Unlock()
select {
case err := <-pauseDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Pause did not finish")
}
waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 3*time.Second)
if got := manager.starts.Load(); got != 1 {
t.Fatalf("stale start generation reached Fetcher.Start: got %d starts, want 1", got)
}
}
func TestDownloader_PauseWinsAgainstStaleResolveError(t *testing.T) {
releaseResolve := make(chan struct{})
manager := &generationTestManager{
resolveStarted: make(chan struct{}),
resolveRelease: releaseResolve,
resolveErr: errors.New("stale resolve failed"),
}
downloader := NewDownloader(&DownloaderConfig{FetchManagers: []fetcher.FetcherManager{manager}})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
errorEvent := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyError {
errorEvent <- event.Err
}
})
id, err := downloader.CreateDirect(&base.Request{URL: "generation://stale-resolve"}, &base.Options{
Path: t.TempDir(),
Name: "stale.bin",
})
if err != nil {
t.Fatal(err)
}
select {
case <-manager.resolveStarted:
case <-time.After(2 * time.Second):
t.Fatal("Resolve did not start")
}
pauseDone := make(chan error, 1)
go func() {
pauseDone <- downloader.Pause(&TaskFilter{IDs: []string{id}})
}()
deadline := time.Now().Add(2 * time.Second)
for downloader.taskStatus(downloader.GetTask(id)) != base.DownloadStatusPause && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if got := downloader.taskStatus(downloader.GetTask(id)); got != base.DownloadStatusPause {
t.Fatalf("Pause did not win the status transition: %s", got)
}
close(releaseResolve)
select {
case err := <-pauseDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Pause did not finish after stale Resolve returned")
}
if got := downloader.taskStatus(downloader.GetTask(id)); got != base.DownloadStatusPause {
t.Fatalf("stale Resolve error overwrote Pause: %s", got)
}
select {
case err := <-errorEvent:
t.Fatalf("stale Resolve emitted onError after Pause: %v", err)
case <-time.After(100 * time.Millisecond):
}
}
func TestDownloader_SchedulingPauseCompletesBeforeReplacementStart(t *testing.T) {
releasePause := make(chan struct{})
manager := &generationTestManager{
holdOpen: true,
pauseStarted: make(chan struct{}),
pauseRelease: releasePause,
}
downloader := NewDownloader(&DownloaderConfig{FetchManagers: []fetcher.FetcherManager{manager}})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
downloader.cfg.MaxRunning = 1
_, err := downloader.CreateDirect(&base.Request{URL: "generation://a"}, &base.Options{
Path: t.TempDir(), Name: "a.bin",
})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for manager.starts.Load() != 1 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if manager.starts.Load() != 1 {
t.Fatal("first task did not physically start")
}
idB, err := downloader.CreateDirect(&base.Request{URL: "generation://b"}, &base.Options{
Path: t.TempDir(), Name: "b.bin",
})
if err != nil {
t.Fatal(err)
}
if downloader.taskStatus(downloader.GetTask(idB)) != base.DownloadStatusWait {
t.Fatal("second task was not queued")
}
continueDone := make(chan error, 1)
go func() {
continueDone <- downloader.Continue(&TaskFilter{IDs: []string{idB}})
}()
select {
case <-manager.pauseStarted:
case <-time.After(2 * time.Second):
t.Fatal("running task was not physically paused")
}
if got := manager.starts.Load(); got != 1 {
t.Fatalf("replacement started before Pause completed: %d starts", got)
}
close(releasePause)
select {
case err := <-continueDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Continue did not return after Pause completed")
}
deadline = time.Now().Add(2 * time.Second)
for manager.starts.Load() != 2 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if got := manager.starts.Load(); got != 2 {
t.Fatalf("replacement task did not start: %d starts", got)
}
if got := manager.pauses.Load(); got != 1 {
t.Fatalf("unexpected physical Pause count: %d", got)
}
}
@@ -120,7 +703,8 @@ func TestDownloader_Create(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt())
opts := newTestDownloadOpt(t)
rr, err := downloader.Resolve(req, opts)
if err != nil {
t.Fatal(err)
}
@@ -138,7 +722,7 @@ func TestDownloader_Create(t *testing.T) {
}
wg.Wait()
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
got := test.FileMd5(filepath.Join(opts.Path, opts.Name))
if want != got {
t.Errorf("Downloader_Create() got = %v, want %v", got, want)
}
@@ -160,7 +744,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
}
// With new fetcher design, white list check happens during Resolve (not Create)
// because Resolve now requires Options which includes the download path
_, err := downloader.Resolve(req, newTestDownloadOpt())
_, err := downloader.Resolve(req, newTestDownloadOpt(t))
if err == nil {
t.Error("TestDownloader_CreateNotInWhite() expected error but got nil")
}
@@ -207,9 +791,10 @@ func TestDownloader_CreateDirectBatch(t *testing.T) {
}
})
opts := newTestDownloadOpt(t)
_, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{
Reqs: reqs,
Opts: newTestDownloadOpt(),
Opts: opts,
})
if err != nil {
t.Fatal(err)
@@ -235,7 +820,7 @@ func TestDownloader_CreateDirectBatch(t *testing.T) {
// Check that all task files exist
for name := range taskNames {
if _, err := os.Stat(test.Dir + "/" + name); os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(opts.Path, name)); os.IsNotExist(err) {
t.Errorf("CreateDirectBatch() file not exist: %v", name)
}
}
@@ -388,6 +973,7 @@ func TestDownloader_CreateRename(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
downloadDir := t.TempDir()
var wg sync.WaitGroup
wg.Add(2)
downloader.Listener(func(event *Event) {
@@ -397,7 +983,7 @@ func TestDownloader_CreateRename(t *testing.T) {
})
for i := 0; i < 2; i++ {
_, err := downloader.CreateDirect(req, &base.Options{
Path: test.Dir,
Path: downloadDir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
@@ -410,11 +996,11 @@ func TestDownloader_CreateRename(t *testing.T) {
wg.Wait()
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
got := test.FileMd5(filepath.Join(downloadDir, test.DownloadName))
if want != got {
t.Errorf("Downloader_CreateRename() got = %v, want %v", got, want)
}
got = test.FileMd5(test.DownloadRenameFile)
got = test.FileMd5(filepath.Join(downloadDir, test.DownloadRename))
if want != got {
t.Errorf("Downloader_CreateRename() got = %v, want %v", got, want)
}
@@ -424,8 +1010,9 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
listener := test.StartTestSlowFileServer(time.Millisecond * 2000)
defer listener.Close()
storageDir := t.TempDir()
downloader := NewDownloader(&DownloaderConfig{
Storage: NewBoltStorage("./"),
Storage: NewBoltStorage(storageDir),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
@@ -434,24 +1021,37 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt())
opts := newTestDownloadOpt(t)
rr, err := downloader.Resolve(req, opts)
if err != nil {
t.Fatal(err)
}
pauseResult := make(chan error, 1)
var pauseOnce sync.Once
downloader.Listener(func(event *Event) {
if event.Key == EventKeyStart {
pauseOnce.Do(func() {
pauseResult <- downloader.Pause(&TaskFilter{IDs: []string{event.Task.ID}})
})
}
})
id, err := downloader.Create(rr.ID)
if err != nil {
t.Fatal(err)
}
time.Sleep(time.Millisecond * 1001)
err = downloader.Pause(&TaskFilter{IDs: []string{id}})
if err != nil {
t.Fatal(err)
select {
case err = <-pauseResult:
if err != nil {
t.Fatal(err)
}
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for task to pause")
}
downloader.Close()
downloader = NewDownloader(&DownloaderConfig{
Storage: NewBoltStorage("./"),
Storage: NewBoltStorage(storageDir),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
@@ -474,7 +1074,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
t.Fatal(err)
}
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
got := test.FileMd5(filepath.Join(opts.Path, opts.Name))
if want != got {
t.Errorf("StoreAndResume() got = %v, want %v", got, want)
}
@@ -568,15 +1168,10 @@ func TestDownloader_GetTasksByFilter(t *testing.T) {
}
})
batchOpts := newTestDownloadOpt(t)
taskIds, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{
Reqs: reqs,
Opts: &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
},
},
Opts: batchOpts,
})
if err != nil {
t.Fatal(err)
@@ -703,7 +1298,7 @@ func TestDownloader_Stats(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt())
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -746,6 +1341,7 @@ func TestDownloader_Delete(t *testing.T) {
// Create multiple tasks
var wg sync.WaitGroup
taskCount := 3
downloadDir := t.TempDir()
wg.Add(taskCount)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyDone {
@@ -758,7 +1354,7 @@ func TestDownloader_Delete(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
taskId, err := downloader.CreateDirect(req, newTestDownloadOpt())
taskId, err := downloader.CreateDirect(req, newTestDownloadOptAt(downloadDir))
if err != nil {
t.Fatal(err)
}
@@ -822,7 +1418,7 @@ func TestDownloader_PauseAndContinue(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt())
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -893,7 +1489,7 @@ func TestDownloader_PauseAllAndContinueAll(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt())
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -1553,6 +2149,7 @@ func TestDownloader_DeleteAll(t *testing.T) {
// Create multiple tasks
var wg sync.WaitGroup
taskCount := 3
downloadDir := t.TempDir()
wg.Add(taskCount)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyDone {
@@ -1565,7 +2162,7 @@ func TestDownloader_DeleteAll(t *testing.T) {
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
_, err := downloader.CreateDirect(req, &base.Options{
Path: test.Dir,
Path: downloadDir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
@@ -2719,7 +3316,7 @@ func TestDownloader_PatchTask_HTTP(t *testing.T) {
},
}
opts := &base.Options{
Path: test.Dir,
Path: t.TempDir(),
Name: test.DownloadName,
}
-15
View File
@@ -47,21 +47,6 @@ func TestError(t *testing.T) {
}
}
func TestURLCreateObjectURLRejectsUnsupportedType(t *testing.T) {
engine := NewEngine(nil)
defer engine.Close()
_, err := engine.RunString(`
URL.createObjectURL({});
`)
if err == nil {
t.Fatal("expected unsupported type error")
}
if !strings.Contains(err.Error(), "Unsupported object type for URL.createObjectURL: Object") {
t.Fatalf("unexpected unsupported type error: %v", err)
}
}
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
engine := NewEngine(nil)
defer engine.Close()
+645 -153
View File
@@ -11,9 +11,9 @@ import (
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
protogblob "github.com/GopeedLab/gopeed/internal/protocol/gblob"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
@@ -27,105 +27,68 @@ import (
var script string
type Config struct {
CreateBlobObjectURL func(data []byte, contentType string) (string, error)
CreateWritableStreamObjectURL func(opts *WritableStreamObjectURLOptions) (string, error)
RegisterWritableStreamResume func(url string, reopen func(offset int64) error) error
WriteWritableStreamObjectURL func(url string, data any) error
CloseWritableStreamObjectURL func(url string) error
AbortWritableStreamObjectURL func(url string, reason string) error
RevokeObjectURL func(url string) error
ProxyHandler func(r *http.Request) (*url.URL, error)
CreateObjectURL func(opts *ObjectURLOptions, open ObjectURLOpener) (string, error)
RevokeObjectURL func(url string) error
ProxyHandler func(r *http.Request) (*url.URL, error)
RegisterCleanup func(cleanup func())
}
type WritableStreamObjectURLOptions struct {
Reopenable bool
type ObjectURLOptions struct {
ContentType string
Size int64
Range bool
}
func isIgnorableGBlobError(err error) bool {
return errors.Is(err, protogblob.ErrSourceRevoked) ||
errors.Is(err, protogblob.ErrSourceNotFound) ||
errors.Is(err, protogblob.ErrSourceClosed) ||
errors.Is(err, protogblob.ErrSourceAborted)
type ObjectURLOpenRequest struct {
Offset int64
End int64
}
type ObjectURLOpener func(ctx context.Context, req ObjectURLOpenRequest) (io.ReadCloser, error)
func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
if cfg == nil {
cfg = &Config{}
}
if err := runtime.Set("__gopeed_create_blob_object_url", func(data []byte, contentType string) string {
if cfg.CreateBlobObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("gblob blob object url handler not configured")))
blobPipes := newBlobPipeRegistry()
if err := runtime.Set("__gopeed_create_blob_object_url", func(call goja.FunctionCall) goja.Value {
if cfg.CreateObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("blob object url handler not configured")))
}
url, err := cfg.CreateBlobObjectURL(data, contentType)
openValue := call.Argument(0)
if _, ok := goja.AssertFunction(openValue); !ok {
panic(runtime.NewGoError(fmt.Errorf("blob opener must be callable")))
}
optsObj := call.Argument(1).ToObject(runtime)
size := int64(0)
contentType := ""
rangeEnabled := false
if optsObj != nil {
size = optsObj.Get("size").ToInteger()
contentType = optsObj.Get("contentType").String()
rangeEnabled = optsObj.Get("range").ToBoolean()
}
if size < 0 {
size = 0
}
opts := &ObjectURLOptions{
ContentType: contentType,
Size: size,
Range: rangeEnabled,
}
url, err := cfg.CreateObjectURL(opts, func(ctx context.Context, req ObjectURLOpenRequest) (io.ReadCloser, error) {
return openBlobObjectURLReader(ctx, loop, blobPipes, openValue, req)
})
if err != nil {
panic(runtime.NewGoError(err))
}
return url
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_create_writable_stream_object_url", func(reopenable bool) string {
if cfg.CreateWritableStreamObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream object url handler not configured")))
}
opts := &WritableStreamObjectURLOptions{Reopenable: reopenable}
url, err := cfg.CreateWritableStreamObjectURL(opts)
if err != nil {
panic(runtime.NewGoError(err))
}
if opts.Reopenable && cfg.RegisterWritableStreamResume != nil {
if err := cfg.RegisterWritableStreamResume(url, func(offset int64) error {
return openWritableStreamObjectURL(loop, url, offset)
}); err != nil {
panic(runtime.NewGoError(err))
}
}
return url
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_write_writable_stream_object_url", func(url string, data any) {
if cfg.WriteWritableStreamObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream write handler not configured")))
}
if err := cfg.WriteWritableStreamObjectURL(url, data); err != nil {
if isIgnorableGBlobError(err) {
return
}
panic(runtime.NewGoError(err))
}
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_close_writable_stream_object_url", func(url string) {
if cfg.CloseWritableStreamObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream close handler not configured")))
}
if err := cfg.CloseWritableStreamObjectURL(url); err != nil {
if isIgnorableGBlobError(err) {
return
}
panic(runtime.NewGoError(err))
}
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_abort_writable_stream_object_url", func(url string, reason string) {
if cfg.AbortWritableStreamObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream abort handler not configured")))
}
if err := cfg.AbortWritableStreamObjectURL(url, reason); err != nil {
if isIgnorableGBlobError(err) {
return
}
panic(runtime.NewGoError(err))
}
return runtime.ToValue(url)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_revoke_object_url", func(url string) {
if cfg.RevokeObjectURL == nil {
panic(runtime.NewGoError(fmt.Errorf("gblob revoke handler not configured")))
panic(runtime.NewGoError(fmt.Errorf("blob revoke handler not configured")))
}
if err := cfg.RevokeObjectURL(url); err != nil {
panic(runtime.NewGoError(err))
@@ -134,30 +97,55 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error
return err
}
fetchRegistry := newFetchRegistry()
if cfg.RegisterCleanup != nil {
cfg.RegisterCleanup(fetchRegistry.CloseAll)
}
if err := runtime.Set("__gopeed_fetch_open", func(call goja.FunctionCall) goja.Value {
reqMeta, err := exportFetchRequest(runtime, call.Argument(0))
if err != nil {
panic(runtime.NewGoError(err))
}
meta, err := fetchRegistry.Open(runtime, cfg.ProxyHandler, reqMeta)
if err != nil {
panic(runtime.NewGoError(err))
}
return runtime.ToValue(meta)
fingerprint := util.SafeGet[string](runtime, xhr.FingerprintMagicKey)
promise, resolve, reject := runtime.NewPromise()
go func() {
meta, err := fetchRegistry.Open(fingerprint, cfg.ProxyHandler, reqMeta)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
if err != nil {
reject(runtime.NewGoError(err))
return
}
resolve(runtime.ToValue(meta))
})
if !ok && meta != nil {
fetchRegistry.Close(meta.ID)
}
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_read", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
chunkSize := int(call.Argument(1).ToInteger())
chunk, done, err := fetchRegistry.Read(id, chunkSize)
if err != nil {
panic(runtime.NewGoError(err))
}
if done {
return goja.Null()
}
return runtime.ToValue(runtime.NewArrayBuffer(chunk))
promise, resolve, reject := runtime.NewPromise()
go func() {
chunk, done, err := fetchRegistry.Read(id, chunkSize)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
if err != nil {
reject(runtime.NewGoError(err))
return
}
if done {
resolve(goja.Null())
return
}
resolve(runtime.ToValue(runtime.NewArrayBuffer(chunk)))
})
if !ok {
fetchRegistry.Close(id)
}
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
@@ -171,19 +159,323 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
ok := blobPipes.Push(id, exportBytes(call.Argument(1)))
return runtime.ToValue(ok)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_blob_pipe_close", func(id string) {
blobPipes.Close(id, nil)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_blob_pipe_error", func(id string, message string) {
blobPipes.Close(id, errors.New(message))
}); err != nil {
return err
}
_, err := runtime.RunString(script)
return err
}
func openWritableStreamObjectURL(loop *eventloop.EventLoop, url string, offset int64) error {
func openBlobObjectURLReader(ctx context.Context, loop *eventloop.EventLoop, pipes *blobPipeRegistry, openValue goja.Value, req ObjectURLOpenRequest) (io.ReadCloser, error) {
if pipes == nil {
return nil, fmt.Errorf("blob pipe registry not configured")
}
pipe := newBlobPipe(ctx, loop, pipes)
pipes.Add(pipe)
_, err := runOnLoop(loop, func(runtime *goja.Runtime) (goja.Value, error) {
fnVal := runtime.Get("__gopeed_blob_pipe_source")
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return nil, fmt.Errorf("blob pipe helper is not callable")
}
request := map[string]any{
"offset": req.Offset,
"end": req.End,
}
return fn(nil, openValue, runtime.ToValue(request), runtime.ToValue(pipe.id))
})
if err != nil {
pipe.Close()
return nil, err
}
return pipe, nil
}
type blobPipeRegistry struct {
mu sync.Mutex
pipes map[string]*blobPipe
next atomic.Uint64
}
func newBlobPipeRegistry() *blobPipeRegistry {
return &blobPipeRegistry{
pipes: map[string]*blobPipe{},
}
}
func (r *blobPipeRegistry) Add(pipe *blobPipe) {
r.mu.Lock()
r.pipes[pipe.id] = pipe
r.mu.Unlock()
}
func (r *blobPipeRegistry) Remove(id string) {
r.mu.Lock()
delete(r.pipes, id)
r.mu.Unlock()
}
func (r *blobPipeRegistry) Push(id string, chunk []byte) bool {
r.mu.Lock()
pipe := r.pipes[id]
r.mu.Unlock()
if pipe == nil {
return false
}
return pipe.Push(chunk)
}
func (r *blobPipeRegistry) Close(id string, err error) {
r.mu.Lock()
pipe := r.pipes[id]
r.mu.Unlock()
if pipe != nil {
pipe.CloseWithError(err)
}
}
func (r *blobPipeRegistry) NewID() string {
return fmt.Sprintf("pipe-%d", r.next.Add(1))
}
type blobPipeItem struct {
chunk []byte
err error
done bool
}
type blobPipe struct {
id string
loop *eventloop.EventLoop
registry *blobPipeRegistry
ctx context.Context
cancel context.CancelFunc
ch chan blobPipeItem
mu sync.Mutex
buf []byte
closed bool
once sync.Once
}
func newBlobPipe(ctx context.Context, loop *eventloop.EventLoop, registry *blobPipeRegistry) *blobPipe {
pipeCtx, cancel := context.WithCancel(ctx)
return &blobPipe{
id: registry.NewID(),
loop: loop,
registry: registry,
ctx: pipeCtx,
cancel: cancel,
ch: make(chan blobPipeItem, 8),
}
}
func (p *blobPipe) Push(chunk []byte) bool {
if len(chunk) == 0 {
return true
}
buf := append([]byte(nil), chunk...)
select {
case p.ch <- blobPipeItem{chunk: buf}:
return true
case <-p.ctx.Done():
return false
}
}
func (p *blobPipe) CloseWithError(err error) {
p.once.Do(func() {
p.registry.Remove(p.id)
select {
case p.ch <- blobPipeItem{done: true, err: err}:
case <-p.ctx.Done():
}
})
}
func (p *blobPipe) Read(dst []byte) (int, error) {
p.mu.Lock()
if len(p.buf) > 0 {
n := copy(dst, p.buf)
p.buf = p.buf[n:]
p.mu.Unlock()
return n, nil
}
p.mu.Unlock()
select {
case item := <-p.ch:
if item.done {
if item.err != nil {
return 0, item.err
}
return 0, io.EOF
}
n := copy(dst, item.chunk)
if n < len(item.chunk) {
p.mu.Lock()
p.buf = append(p.buf, item.chunk[n:]...)
p.mu.Unlock()
}
return n, nil
case <-p.ctx.Done():
return 0, p.ctx.Err()
}
}
func (p *blobPipe) Close() error {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return nil
}
p.closed = true
p.mu.Unlock()
p.registry.Remove(p.id)
p.cancel()
p.cancelSourceReader()
return nil
}
func (p *blobPipe) cancelSourceReader() {
if p.loop == nil {
return
}
// Invoke the JavaScript cancellation before returning from Close. The
// source session may be released immediately after the HTTP handler closes
// this reader, so merely queueing the callback could let the engine stop
// before reader.cancel() is called.
_, _ = runOnLoop(p.loop, func(runtime *goja.Runtime) (goja.Value, error) {
fnVal := runtime.Get("__gopeed_blob_cancel_pipe_source")
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return goja.Undefined(), nil
}
_, err := fn(nil, runtime.ToValue(p.id), runtime.ToValue("blob request closed"))
return goja.Undefined(), err
})
}
func exportBytes(value goja.Value) []byte {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return nil
}
if ab, ok := value.Export().(goja.ArrayBuffer); ok {
return ab.Bytes()
}
if b, ok := value.Export().([]byte); ok {
return b
}
return nil
}
type blobObjectURLReader struct {
ctx context.Context
loop *eventloop.EventLoop
id string
mu sync.Mutex
buf []byte
closed bool
}
func (r *blobObjectURLReader) Read(p []byte) (int, error) {
r.mu.Lock()
if len(r.buf) > 0 {
n := copy(p, r.buf)
r.buf = r.buf[n:]
r.mu.Unlock()
return n, nil
}
closed := r.closed
r.mu.Unlock()
if closed {
return 0, io.EOF
}
if err := r.ctx.Err(); err != nil {
return 0, err
}
chunkSize := len(p)
if chunkSize <= 0 {
chunkSize = 32 * 1024
}
value, err := runOnLoop(r.loop, func(runtime *goja.Runtime) (goja.Value, error) {
fnVal := runtime.Get("__gopeed_blob_read_source")
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return nil, fmt.Errorf("blob read helper is not callable")
}
return fn(nil, runtime.ToValue(r.id), runtime.ToValue(chunkSize))
})
if err != nil {
return 0, err
}
if goja.IsNull(value) || goja.IsUndefined(value) {
return 0, io.EOF
}
var chunk []byte
if ab, ok := value.Export().(goja.ArrayBuffer); ok {
chunk = ab.Bytes()
} else if b, ok := value.Export().([]byte); ok {
chunk = b
} else {
return 0, fmt.Errorf("blob read helper returned %T", value.Export())
}
if len(chunk) == 0 {
return 0, nil
}
n := copy(p, chunk)
if n < len(chunk) {
r.mu.Lock()
r.buf = append(r.buf, chunk[n:]...)
r.mu.Unlock()
}
return n, nil
}
func (r *blobObjectURLReader) Close() error {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil
}
r.closed = true
r.mu.Unlock()
_, err := runOnLoop(r.loop, func(runtime *goja.Runtime) (goja.Value, error) {
fnVal := runtime.Get("__gopeed_blob_close_source")
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return nil, nil
}
return fn(nil, runtime.ToValue(r.id))
})
return err
}
func runOnLoop(loop *eventloop.EventLoop, fn func(runtime *goja.Runtime) (goja.Value, error)) (goja.Value, error) {
type result struct {
err error
value goja.Value
err error
}
ch := make(chan result, 1)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
send := func(err error) {
send := func(value goja.Value, err error) {
select {
case ch <- result{err: err}:
case ch <- result{value: value, err: err}:
default:
}
}
@@ -191,45 +483,55 @@ func openWritableStreamObjectURL(loop *eventloop.EventLoop, url string, offset i
if r := recover(); r != nil {
switch v := r.(type) {
case error:
send(v)
send(nil, v)
case goja.Value:
send(exportJSError(v))
send(nil, exportJSError(v))
default:
send(fmt.Errorf("panic: %v", r))
send(nil, fmt.Errorf("panic: %v", r))
}
}
}()
fnVal := runtime.Get("__gopeed_open_writable_stream_object_url")
fn, ok := goja.AssertFunction(fnVal)
if !ok {
send(fmt.Errorf("gblob writable stream open helper is not callable"))
return
}
value, err := fn(nil, runtime.ToValue(url), runtime.ToValue(offset))
value, err := fn(runtime)
if err != nil {
send(err)
send(nil, err)
return
}
if promise, ok := value.Export().(*goja.Promise); ok {
switch promise.State() {
case goja.PromiseStateFulfilled:
send(nil)
send(promise.Result(), nil)
return
case goja.PromiseStateRejected:
send(exportJSError(promise.Result()))
send(nil, exportJSError(promise.Result()))
return
default:
send(nil)
thenVal := value.ToObject(runtime).Get("then")
thenFn, ok := goja.AssertFunction(thenVal)
if !ok {
send(nil, errors.New("promise.then is not callable"))
return
}
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
send(call.Argument(0), nil)
return goja.Undefined()
})
onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
send(nil, exportJSError(call.Argument(0)))
return goja.Undefined()
})
if _, err := thenFn(value, onFulfilled, onRejected); err != nil {
send(nil, err)
}
return
}
}
send(nil)
send(value, nil)
})
if !ok {
return errors.New("engine loop terminated")
return nil, errors.New("engine loop terminated")
}
return (<-ch).err
res := <-ch
return res.value, res.err
}
func exportJSError(value goja.Value) error {
@@ -252,12 +554,24 @@ func exportJSError(value goja.Value) error {
type fetchRegistry struct {
mu sync.Mutex
streams map[string]*fetchStream
ctx context.Context
cancel context.CancelFunc
closed bool
}
type fetchStream struct {
body io.ReadCloser
cancel context.CancelFunc
closeOnce sync.Once
ctx context.Context
ch chan fetchChunk
readMu sync.Mutex
pending []byte
}
type fetchChunk struct {
data []byte
err error
}
type fetchRequest struct {
@@ -269,6 +583,15 @@ type fetchRequest struct {
Credentials string
}
type fetchFormDataEntry struct {
name string
value any
}
type fetchFormDataSnapshot struct {
entries []fetchFormDataEntry
}
type fetchOpenMeta struct {
ID string `json:"id"`
Status int `json:"status"`
@@ -278,8 +601,11 @@ type fetchOpenMeta struct {
}
func newFetchRegistry() *fetchRegistry {
ctx, cancel := context.WithCancel(context.Background())
return &fetchRegistry{
streams: make(map[string]*fetchStream),
ctx: ctx,
cancel: cancel,
}
}
@@ -310,22 +636,98 @@ func exportFetchRequest(runtime *goja.Runtime, value goja.Value) (*fetchRequest,
}
bodyVal := obj.Get("body")
if bodyVal != nil && !goja.IsUndefined(bodyVal) && !goja.IsNull(bodyVal) {
meta.Body = bodyVal.Export()
body, err := snapshotFetchBody(bodyVal.Export())
if err != nil {
return nil, err
}
meta.Body = body
}
return meta, nil
}
func (r *fetchRegistry) Open(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error), reqMeta *fetchRequest) (*fetchOpenMeta, error) {
// snapshotFetchBody runs on the JavaScript event loop before fetch work is
// handed to a background goroutine. Values exported by goja may still share
// mutable storage with JavaScript (notably ArrayBuffer and FormData), so the
// background request must only retain immutable Go-owned data.
func snapshotFetchBody(body any) (any, error) {
switch v := body.(type) {
case nil, string:
return v, nil
case []byte:
return append([]byte(nil), v...), nil
case goja.ArrayBuffer:
return append([]byte(nil), v.Bytes()...), nil
case *file.File:
return snapshotFetchFile(v), nil
case *formdata.FormData:
entries := v.Entries()
snapshot := &fetchFormDataSnapshot{
entries: make([]fetchFormDataEntry, 0, len(entries)),
}
for _, entry := range entries {
pair, ok := entry.([]any)
if !ok || len(pair) != 2 {
return nil, fmt.Errorf("invalid FormData entry %T", entry)
}
name, ok := pair[0].(string)
if !ok {
return nil, fmt.Errorf("invalid FormData field name %T", pair[0])
}
value := pair[1]
if formFile, ok := value.(*file.File); ok {
value = snapshotFetchFile(formFile)
}
snapshot.entries = append(snapshot.entries, fetchFormDataEntry{
name: name,
value: value,
})
}
return snapshot, nil
default:
if typed, ok := v.(interface{ Bytes() []byte }); ok {
return append([]byte(nil), typed.Bytes()...), nil
}
return v, nil
}
}
func snapshotFetchFile(src *file.File) *file.File {
if src == nil {
return nil
}
return &file.File{
Reader: src.Reader,
Closer: src.Closer,
Name: src.Name,
Size: src.Size,
}
}
func (r *fetchRegistry) Open(fingerprint string, proxyHandler func(r *http.Request) (*url.URL, error), reqMeta *fetchRequest) (*fetchOpenMeta, error) {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil, context.Canceled
}
registryCtx := r.ctx
r.mu.Unlock()
client := req.C()
if proxyHandler != nil {
client.SetProxy(proxyHandler)
}
setFetchFingerprint(client, util.SafeGet[string](runtime, xhr.FingerprintMagicKey))
setFetchFingerprint(client, fingerprint)
contentType, body, err := buildFetchBody(reqMeta.Body)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(registryCtx)
keepCancel := false
defer func() {
if !keepCancel {
cancel()
}
}()
reqBuilder := client.R()
reqBuilder.SetContext(ctx)
reqBuilder.DisableAutoReadResponse()
@@ -378,9 +780,22 @@ func (r *fetchRegistry) Open(runtime *goja.Runtime, proxyHandler func(r *http.Re
if resp.Response != nil && resp.Response.Body != nil {
bodyCloser = resp.Response.Body
}
stream := &fetchStream{
body: bodyCloser,
cancel: cancel,
ctx: ctx,
ch: make(chan fetchChunk, 8),
}
r.mu.Lock()
r.streams[id] = &fetchStream{body: bodyCloser, cancel: cancel}
if r.closed {
r.mu.Unlock()
closeFetchStream(stream)
return nil, context.Canceled
}
r.streams[id] = stream
r.mu.Unlock()
keepCancel = true
go stream.readLoop()
return meta, nil
}
@@ -389,23 +804,62 @@ func (r *fetchRegistry) Read(id string, chunkSize int) ([]byte, bool, error) {
if stream == nil {
return nil, true, nil
}
if chunkSize <= 0 {
chunkSize = 64 * 1024
stream.readMu.Lock()
defer stream.readMu.Unlock()
if len(stream.pending) > 0 {
n := len(stream.pending)
if chunkSize > 0 && n > chunkSize {
n = chunkSize
}
chunk := append([]byte(nil), stream.pending[:n]...)
stream.pending = stream.pending[n:]
return chunk, false, nil
}
buf := make([]byte, chunkSize)
n, err := stream.body.Read(buf)
if n > 0 {
return buf[:n], false, nil
select {
case item, ok := <-stream.ch:
if !ok {
r.Close(id)
return nil, true, nil
}
if item.err != nil {
r.Close(id)
return nil, false, item.err
}
if chunkSize > 0 && len(item.data) > chunkSize {
head := append([]byte(nil), item.data[:chunkSize]...)
stream.pending = append(stream.pending[:0], item.data[chunkSize:]...)
return head, false, nil
}
return item.data, false, nil
case <-stream.ctx.Done():
return nil, false, stream.ctx.Err()
}
if err == io.EOF {
r.Close(id)
return nil, true, nil
}
func (s *fetchStream) readLoop() {
defer close(s.ch)
buf := make([]byte, 64*1024)
for {
n, err := s.body.Read(buf)
if n > 0 {
chunk := append([]byte(nil), buf[:n]...)
select {
case s.ch <- fetchChunk{data: chunk}:
case <-s.ctx.Done():
return
}
}
if err == io.EOF {
return
}
if err != nil {
select {
case s.ch <- fetchChunk{err: err}:
case <-s.ctx.Done():
}
return
}
}
if err != nil {
r.Close(id)
return nil, false, err
}
return nil, false, nil
}
func (r *fetchRegistry) Close(id string) {
@@ -413,14 +867,43 @@ func (r *fetchRegistry) Close(id string) {
stream := r.streams[id]
delete(r.streams, id)
r.mu.Unlock()
if stream != nil {
stream.closeOnce.Do(func() {
if stream.cancel != nil {
stream.cancel()
}
_ = stream.body.Close()
})
closeFetchStream(stream)
}
// CloseAll permanently closes the registry and cancels both active and
// in-flight fetches. It is safe to call more than once.
func (r *fetchRegistry) CloseAll() {
if r == nil {
return
}
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
if r.cancel != nil {
r.cancel()
}
streams := r.streams
r.streams = make(map[string]*fetchStream)
r.mu.Unlock()
for _, stream := range streams {
closeFetchStream(stream)
}
}
func closeFetchStream(stream *fetchStream) {
if stream == nil {
return
}
stream.closeOnce.Do(func() {
if stream.cancel != nil {
stream.cancel()
}
_ = stream.body.Close()
})
}
func (r *fetchRegistry) Abort(id string, _ string) {
@@ -445,24 +928,33 @@ func buildFetchBody(body any) (string, any, error) {
return "application/octet-stream", v.Bytes(), nil
case *file.File:
return "application/octet-stream", v.Reader, nil
case *formdata.FormData:
case *fetchFormDataSnapshot:
pr, pw := io.Pipe()
mw := xhr.NewMultipart(pw)
for _, e := range v.Entries() {
arr := e.([]any)
key := arr[0].(string)
val := arr[1]
switch vv := val.(type) {
for _, entry := range v.entries {
switch vv := entry.value.(type) {
case string:
mw.WriteField(key, vv)
if err := mw.WriteField(entry.name, vv); err != nil {
_ = pw.CloseWithError(err)
return "", nil, err
}
case *file.File:
mw.WriteFile(key, vv)
if err := mw.WriteFile(entry.name, vv); err != nil {
_ = pw.CloseWithError(err)
return "", nil, err
}
}
}
go func() {
defer pw.Close()
defer mw.Close()
mw.Send()
if err := mw.Send(); err != nil {
_ = pw.CloseWithError(err)
return
}
if err := mw.Close(); err != nil {
_ = pw.CloseWithError(err)
return
}
_ = pw.Close()
}()
return mw.FormDataContentType(), pr, nil
default:
@@ -0,0 +1,278 @@
package stream_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/download/engine"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
)
type createdObjectURL struct {
opts *stream.ObjectURLOptions
open stream.ObjectURLOpener
}
func TestResponseBlobObjectURLReadsResponseStream(t *testing.T) {
const payload = "response blob payload"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = io.WriteString(w, payload[:8])
w.(http.Flusher).Flush() // force an unknown-length/chunked response
_, _ = io.WriteString(w, payload[8:])
}))
defer server.Close()
created := make(chan createdObjectURL, 1)
engine, cleanup := newStreamTestEngine(t, created)
defer func() {
cleanup()
engine.Close()
}()
value, err := engine.RunString(fmt.Sprintf(`
(async () => {
const response = await fetch(%q);
const blob = await response.blob();
return __gopeed_blob_create_object_url(blob);
})()
`, server.URL))
if err != nil {
t.Fatal(err)
}
if value != "blob:test-1" {
t.Fatalf("unexpected object URL: %#v", value)
}
object := <-created
if object.opts.Size != int64(len(payload)) || object.opts.ContentType != "text/plain" {
t.Fatalf("unexpected object URL options: %#v", object.opts)
}
if !object.opts.Range {
t.Fatal("materialized response Blob should advertise range support")
}
for i := 0; i < 2; i++ {
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
if err != nil {
t.Fatal(err)
}
data, readErr := io.ReadAll(reader)
_ = reader.Close()
if readErr != nil {
t.Fatal(readErr)
}
if got := string(data); got != payload {
t.Fatalf("unexpected response Blob data on open %d: %q", i+1, got)
}
}
rangeReader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 9, End: 12})
if err != nil {
t.Fatal(err)
}
rangeData, readErr := io.ReadAll(rangeReader)
_ = rangeReader.Close()
if readErr != nil {
t.Fatal(readErr)
}
if got, want := string(rangeData), payload[9:13]; got != want {
t.Fatalf("unexpected materialized Blob range: got %q want %q", got, want)
}
}
func TestResponseBlobRejectsTruncatedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "64")
_, _ = io.WriteString(w, "partial")
}))
defer server.Close()
created := make(chan createdObjectURL, 1)
engine, cleanup := newStreamTestEngine(t, created)
defer func() {
cleanup()
engine.Close()
}()
value, err := engine.RunString(fmt.Sprintf(`
(async () => {
const response = await fetch(%q);
try {
await response.blob();
return "resolved";
} catch (_) {
return "rejected";
}
})()
`, server.URL))
if err != nil {
t.Fatal(err)
}
if value != "rejected" {
t.Fatalf("truncated response unexpectedly materialized a Blob: %#v", value)
}
}
func TestEmptyBlobObjectURL(t *testing.T) {
created := make(chan createdObjectURL, 1)
engine, cleanup := newStreamTestEngine(t, created)
defer func() {
cleanup()
engine.Close()
}()
value, err := engine.RunString(`
__gopeed_blob_create_object_url(new Blob([], { type: "application/empty" }))
`)
if err != nil {
t.Fatal(err)
}
if value != "blob:test-1" {
t.Fatalf("unexpected object URL: %#v", value)
}
object := <-created
if object.opts.Size != 0 || object.opts.Range {
t.Fatalf("unexpected empty Blob options: %#v", object.opts)
}
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
if err != nil {
t.Fatal(err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if len(data) != 0 {
t.Fatalf("expected empty Blob, got %q", data)
}
}
func TestBlobObjectURLReaderCloseCancelsPendingJSReader(t *testing.T) {
created := make(chan createdObjectURL, 1)
engine, cleanup := newStreamTestEngine(t, created)
defer func() {
cleanup()
engine.Close()
}()
value, err := engine.RunString(`
globalThis.__blobPipeCancelState = {
readCalls: 0,
cancelCalls: 0,
releaseCalls: 0,
cancelReason: "",
resolveRead: null,
};
const pendingSource = {
getReader() {
return {
read() {
__blobPipeCancelState.readCalls++;
return new Promise((resolve) => {
__blobPipeCancelState.resolveRead = resolve;
});
},
cancel(reason) {
__blobPipeCancelState.cancelCalls++;
__blobPipeCancelState.cancelReason = String(reason);
if (__blobPipeCancelState.resolveRead) {
__blobPipeCancelState.resolveRead({ done: true, value: undefined });
}
return Promise.resolve();
},
releaseLock() {
__blobPipeCancelState.releaseCalls++;
},
};
},
};
__gopeed_blob_create_object_url(async () => pendingSource);
`)
if err != nil {
t.Fatal(err)
}
if value != "blob:test-1" {
t.Fatalf("unexpected object URL: %#v", value)
}
object := <-created
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
if err != nil {
t.Fatal(err)
}
readDone := make(chan error, 1)
go func() {
_, readErr := reader.Read(make([]byte, 1))
readDone <- readErr
}()
waitForJSValue(t, engine, `String(__blobPipeCancelState.readCalls)`, "1")
if err := reader.Close(); err != nil {
t.Fatal(err)
}
if err := reader.Close(); err != nil {
t.Fatal(err)
}
select {
case readErr := <-readDone:
if !errors.Is(readErr, context.Canceled) {
t.Fatalf("unexpected pending read result: %v", readErr)
}
case <-time.After(2 * time.Second):
t.Fatal("pending Go read was not released by Close")
}
waitForJSValue(t, engine, `JSON.stringify({
cancelCalls: __blobPipeCancelState.cancelCalls,
releaseCalls: __blobPipeCancelState.releaseCalls,
cancelReason: __blobPipeCancelState.cancelReason,
})`, `{"cancelCalls":1,"releaseCalls":1,"cancelReason":"blob request closed"}`)
}
func waitForJSValue(t *testing.T, engine *engine.Engine, expression, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
var got any
for time.Now().Before(deadline) {
var err error
got, err = engine.RunString(expression)
if err != nil {
t.Fatal(err)
}
if got == want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for JavaScript value %q, got %#v", want, got)
}
func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine.Engine, func()) {
t.Helper()
var next atomic.Int64
var cleanup func()
e := engine.NewEngine(&engine.Config{
StreamConfig: &stream.Config{
CreateObjectURL: func(opts *stream.ObjectURLOptions, open stream.ObjectURLOpener) (string, error) {
created <- createdObjectURL{opts: opts, open: open}
return fmt.Sprintf("blob:test-%d", next.Add(1)), nil
},
RevokeObjectURL: func(string) error { return nil },
RegisterCleanup: func(fn func()) {
cleanup = fn
},
},
})
if cleanup == nil {
t.Fatal("stream cleanup was not registered")
}
return e, cleanup
}
@@ -0,0 +1,191 @@
package stream
import (
"context"
"io"
"strings"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/dop251/goja"
)
func TestExportFetchRequestSnapshotsArrayBuffer(t *testing.T) {
runtime := goja.New()
buffer := runtime.NewArrayBuffer([]byte("before"))
request := runtime.NewObject()
setFetchRequestDefaults(t, request)
if err := request.Set("body", buffer); err != nil {
t.Fatal(err)
}
meta, err := exportFetchRequest(runtime, request)
if err != nil {
t.Fatal(err)
}
buffer.Bytes()[0] = 'X'
body, ok := meta.Body.([]byte)
if !ok {
t.Fatalf("expected byte snapshot, got %T", meta.Body)
}
if got := string(body); got != "before" {
t.Fatalf("ArrayBuffer snapshot changed after export: %q", got)
}
}
func TestExportFetchRequestSnapshotsFormData(t *testing.T) {
runtime := goja.New()
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
if err := formdata.Enable(runtime); err != nil {
t.Fatal(err)
}
value, err := runtime.RunString(`
globalThis.form = new FormData();
form.append("field", "before");
form;
`)
if err != nil {
t.Fatal(err)
}
request := runtime.NewObject()
setFetchRequestDefaults(t, request)
if err := request.Set("body", value); err != nil {
t.Fatal(err)
}
meta, err := exportFetchRequest(runtime, request)
if err != nil {
t.Fatal(err)
}
if _, err := runtime.RunString(`form.set("field", "after")`); err != nil {
t.Fatal(err)
}
body, ok := meta.Body.(*fetchFormDataSnapshot)
if !ok {
t.Fatalf("expected FormData snapshot, got %T", meta.Body)
}
if len(body.entries) != 1 {
t.Fatalf("expected one FormData entry, got %d", len(body.entries))
}
if got := body.entries[0]; got.name != "field" || got.value != "before" {
t.Fatalf("unexpected FormData snapshot: %#v", got)
}
}
func setFetchRequestDefaults(t *testing.T, request *goja.Object) {
t.Helper()
for key, value := range map[string]any{
"url": "https://example.test",
"method": "POST",
"redirect": "follow",
"credentials": "same-origin",
} {
if err := request.Set(key, value); err != nil {
t.Fatal(err)
}
}
}
func TestSnapshotFetchBodyCopiesFileMetadata(t *testing.T) {
source := &file.File{
Reader: strings.NewReader("payload"),
Name: "before.txt",
Size: 7,
}
value, err := snapshotFetchBody(source)
if err != nil {
t.Fatal(err)
}
snapshot, ok := value.(*file.File)
if !ok {
t.Fatalf("expected file snapshot, got %T", value)
}
source.Name = "after.txt"
source.Size = 99
if snapshot.Name != "before.txt" || snapshot.Size != 7 {
t.Fatalf("file metadata was not snapshotted: %#v", snapshot)
}
}
func TestFetchRegistryCloseAll(t *testing.T) {
registry := newFetchRegistry()
streamCtx, cancel := context.WithCancel(context.Background())
body := &trackingReadCloser{}
registry.streams["active"] = &fetchStream{
body: body,
cancel: cancel,
ctx: streamCtx,
ch: make(chan fetchChunk),
}
registry.CloseAll()
registry.CloseAll()
select {
case <-streamCtx.Done():
default:
t.Fatal("active fetch context was not canceled")
}
if got := body.closeCalls.Load(); got != 1 {
t.Fatalf("expected body to close once, got %d", got)
}
registry.mu.Lock()
closed := registry.closed
remaining := len(registry.streams)
registry.mu.Unlock()
if !closed || remaining != 0 {
t.Fatalf("registry not fully closed: closed=%v remaining=%d", closed, remaining)
}
if _, err := registry.Open("", nil, &fetchRequest{URL: "https://example.test"}); err == nil {
t.Fatal("expected a closed registry to reject new fetches")
}
}
func TestFetchRegistryReadPreservesChunkOrder(t *testing.T) {
registry := newFetchRegistry()
ctx, cancel := context.WithCancel(context.Background())
stream := &fetchStream{
body: io.NopCloser(strings.NewReader("")),
cancel: cancel,
ctx: ctx,
ch: make(chan fetchChunk, 2),
}
stream.ch <- fetchChunk{data: []byte("abc")}
stream.ch <- fetchChunk{data: []byte("def")}
registry.streams["ordered"] = stream
defer registry.CloseAll()
var got strings.Builder
for range 4 {
chunk, done, err := registry.Read("ordered", 2)
if err != nil {
t.Fatal(err)
}
if done {
t.Fatal("stream ended before buffered chunks were read")
}
got.Write(chunk)
}
if got.String() != "abcdef" {
t.Fatalf("fetch chunks were reordered: %q", got.String())
}
}
type trackingReadCloser struct {
closeCalls atomic.Int32
}
func (r *trackingReadCloser) Read([]byte) (int, error) {
return 0, io.EOF
}
func (r *trackingReadCloser) Close() error {
r.closeCalls.Add(1)
return nil
}
+297 -207
View File
@@ -1,9 +1,5 @@
(function () {
const createBlobObjectURL = globalThis.__gopeed_create_blob_object_url;
const createWritableObjectURL = globalThis.__gopeed_create_writable_stream_object_url;
const writeWritableObjectURL = globalThis.__gopeed_write_writable_stream_object_url;
const closeWritableObjectURL = globalThis.__gopeed_close_writable_stream_object_url;
const abortWritableObjectURL = globalThis.__gopeed_abort_writable_stream_object_url;
const revokeObjectURL = globalThis.__gopeed_revoke_object_url;
const fetchOpen = globalThis.__gopeed_fetch_open;
const fetchRead = globalThis.__gopeed_fetch_read;
@@ -447,38 +443,6 @@
return merged;
}
function createStreamBackedBlob(stream, contentType, size) {
const blob = new Blob([], { type: contentType || "" });
if (Number.isFinite(size) && size >= 0) {
try {
Object.defineProperty(blob, "size", {
configurable: true,
enumerable: true,
value: size,
});
} catch (_) {
}
}
blob.__gopeedStreamBackedBlob = true;
blob.__gopeedBodyStream = stream;
blob.stream = function () {
const bodyStream = this.__gopeedBodyStream;
this.__gopeedBodyStream = null;
if (bodyStream instanceof ReadableStream) {
return bodyStream;
}
return new Blob([], { type: this.type || "" }).stream();
};
blob.arrayBuffer = async function () {
const bytes = await readAllFromStream(this.stream(), false);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
};
blob.text = async function () {
return readAllFromStream(this.stream(), true);
};
return blob;
}
function attachResponseStreaming(response, stream) {
response.__gopeedBodyStream = stream;
response.__gopeedBodyConsumed = false;
@@ -515,8 +479,11 @@
ensureUnused();
markBodyUsed();
const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : "";
const contentLength = this.headers && this.headers.get ? parseInt(this.headers.get("content-length") || "", 10) : NaN;
return createStreamBackedBlob(stream, contentType, contentLength);
// Response.blob() is a materializing body consumer. Keeping the live
// response stream behind an empty one-shot Blob makes retry, range and a
// second object-URL reader silently produce an empty file.
const bytes = await readAllFromStream(stream, false);
return new Blob([bytes], { type: contentType });
};
response.json = async function () {
const text = await this.text();
@@ -545,23 +512,7 @@
}
}
const originalCreateObjectURL = typeof URL.createObjectURL === "function"
? URL.createObjectURL.bind(URL)
: null;
const originalRevokeObjectURL = typeof URL.revokeObjectURL === "function"
? URL.revokeObjectURL.bind(URL)
: null;
const resumableReadableObjectURLs = new Map();
const activeReadableObjectURLs = new Map();
const readableObjectURLYieldBytes = 256 * 1024;
function isIgnorableGBlobObjectURLError(error) {
const message = error == null ? "" : String(error && error.message ? error.message : error);
return message.indexOf("gblob source revoked") >= 0 ||
message.indexOf("gblob source not found") >= 0 ||
message.indexOf("gblob source closed") >= 0 ||
message.indexOf("gblob source aborted") >= 0;
}
const blobReaders = new Map();
function getValueTypeName(value) {
if (value === null) {
@@ -577,45 +528,48 @@
}
function toReadableStreamReader(value, sourceLabel) {
if (value instanceof ReadableStream) {
if (value && typeof value.getReader === "function") {
return value.getReader();
}
throw new TypeError(sourceLabel + " must return a ReadableStream, got " + getValueTypeName(value));
}
function describeObjectURLValue(value) {
if (value && value.__gopeedStreamBackedBlob) {
const stream = value.__gopeedBodyStream;
value.__gopeedBodyStream = null;
if (stream instanceof ReadableStream) {
return {
kind: "readable",
initialReadable: stream,
openReadable: null,
sourceLabel: "URL.createObjectURL Response blob stream",
};
async function openBlobReadable(blob, request) {
const { offset, end } = request;
const sliced = blob.slice(offset, end >= offset ? end + 1 : undefined);
if (sliced && typeof sliced.stream === "function") {
try {
const stream = sliced.stream();
if (stream && typeof stream.getReader === "function") {
return stream;
}
} catch (_) {
}
}
if (sliced && typeof sliced.arrayBuffer === "function") {
const buffer = await sliced.arrayBuffer();
return new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
throw new TypeError("Blob source cannot be converted to a ReadableStream");
}
function describeObjectURLValue(value) {
if (value instanceof Blob) {
return {
kind: "blob",
value,
};
}
if (value instanceof ReadableStream) {
return {
kind: "readable",
initialReadable: value,
openReadable: null,
sourceLabel: "URL.createObjectURL ReadableStream",
};
}
if (typeof value === "function") {
return {
kind: "opener",
initialReadable: null,
openReadable: value,
sourceLabel: "URL.createObjectURL opener function",
sourceLabel: "gopeed.runtime.blob.createObjectURL opener function",
};
}
return {
@@ -624,13 +578,6 @@
};
}
function startReadableObjectURL(url, state) {
activeReadableObjectURLs.set(url, state);
setTimeout(() => {
void pumpReadableObjectURL(url, state);
}, 0);
}
function releaseReader(reader, reason) {
if (!reader) {
return;
@@ -652,144 +599,287 @@
}
}
function releaseActiveReadable(url, reason) {
const active = activeReadableObjectURLs.get(url);
if (!active) {
return;
function normalizeBlobObjectURLOptions(value, options, defaultRange) {
const normalized = {
contentType: "",
size: 0,
range: !!defaultRange,
};
if (options && typeof options === "object") {
if (typeof options.contentType === "string") {
normalized.contentType = options.contentType;
}
if (Number.isFinite(Number(options.size)) && Number(options.size) > 0) {
normalized.size = Number(options.size);
}
if (typeof options.range === "boolean") {
normalized.range = options.range;
}
}
activeReadableObjectURLs.delete(url);
active.cancelled = true;
releaseReader(active.reader, reason);
if (!normalized.contentType && value && typeof value.type === "string") {
normalized.contentType = value.type;
}
if (!normalized.size && value) {
if (Number.isFinite(Number(value.size)) && Number(value.size) > 0) {
normalized.size = Number(value.size);
} else if (value._buffer && Number.isFinite(Number(value._buffer.byteLength))) {
normalized.size = Number(value._buffer.byteLength);
}
}
return normalized;
}
async function pumpReadableObjectURL(url, state) {
let reader = null;
try {
let source;
if (state.initialReadable) {
source = state.initialReadable;
state.initialReadable = null;
function validateBlobObjectURLOptions(options) {
if (options.range && !(Number.isFinite(Number(options.size)) && Number(options.size) > 0)) {
throw new TypeError("gopeed.runtime.blob.createObjectURL options.range requires a positive options.size");
}
}
function normalizeOpenRequest(request) {
const offset = Math.max(0, Number(request && request.offset) || 0);
const endValue = Number(request && request.end);
const end = Number.isFinite(endValue) ? endValue : -1;
return { offset, end };
}
function yieldBlobPipeTask() {
return new Promise((resolve) => {
if (typeof setTimeout === "function") {
setTimeout(resolve, 0);
} else {
if (typeof state.openReadable !== "function") {
throw new Error("gblob readable stream is not reopenable");
}
source = await state.openReadable(state.offset);
Promise.resolve().then(resolve);
}
reader = toReadableStreamReader(source, state.sourceLabel);
if (activeReadableObjectURLs.get(url) !== state || state.cancelled) {
releaseReader(reader, "stale gblob producer");
reader = null;
return;
}
state.reader = reader;
let bytesSinceYield = 0;
while (!state.cancelled) {
const current = activeReadableObjectURLs.get(url);
if (current !== state) {
releaseReader(reader, "stale gblob producer");
reader = null;
return;
}
const { done, value } = await reader.read();
if (done) {
if (activeReadableObjectURLs.get(url) === state) {
activeReadableObjectURLs.delete(url);
await closeWritableObjectURL(url);
}
releaseReader(reader);
reader = null;
return;
}
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
if (activeReadableObjectURLs.get(url) !== state || state.cancelled) {
releaseReader(reader, "stale gblob producer");
reader = null;
return;
}
await writeWritableObjectURL(url, chunk);
bytesSinceYield += chunk.byteLength;
if (bytesSinceYield >= readableObjectURLYieldBytes) {
bytesSinceYield = 0;
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
releaseReader(reader, "gblob producer cancelled");
reader = null;
} catch (error) {
if (isIgnorableGBlobObjectURLError(error)) {
if (activeReadableObjectURLs.get(url) === state) {
activeReadableObjectURLs.delete(url);
}
if (reader) {
releaseReader(reader, "gblob source closed");
reader = null;
}
return;
}
if (activeReadableObjectURLs.get(url) === state) {
activeReadableObjectURLs.delete(url);
try {
await abortWritableObjectURL(url, error == null ? "" : String(error && error.message ? error.message : error));
} catch (_) {
}
}
if (reader) {
releaseReader(reader, error);
reader = null;
}
}
});
}
globalThis.__gopeed_open_writable_stream_object_url = function (url, offset) {
const openReadable = resumableReadableObjectURLs.get(url);
if (typeof openReadable !== "function") {
throw new Error("gblob resumable opener not found");
function createPendingBlobReader(openReadable, request) {
let activeReader;
let ready;
function ensureReader() {
if (ready) {
return ready;
}
ready = (async function () {
const source = await openReadable(normalizeOpenRequest(request));
activeReader = toReadableStreamReader(source, "gopeed.runtime.blob.createObjectURL opener function");
return activeReader;
})();
return ready;
}
releaseActiveReadable(url, "gblob source reopened");
startReadableObjectURL(url, {
initialReadable: null,
openReadable,
sourceLabel: "URL.createObjectURL opener function",
offset: Number(offset) || 0,
reader: null,
cancelled: false,
});
return {
async read() {
const reader = await ensureReader();
return reader.read();
},
cancel(reason) {
return ensureReader().then(function (reader) {
return reader.cancel(reason);
}, function () {});
},
releaseLock() {
if (!ready) {
return;
}
return ready.then(function (reader) {
return reader.releaseLock();
}, function () {});
}
};
}
globalThis.__gopeed_blob_open_source = function (openReadable, request) {
if (typeof openReadable !== "function") {
throw new TypeError("blob opener must be callable");
}
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + "-" + String(Math.random());
blobReaders.set(id, createPendingBlobReader(openReadable, request));
return id;
};
URL.createObjectURL = function (value) {
globalThis.__gopeed_blob_read_source = async function (id, chunkSize) {
const reader = blobReaders.get(id);
if (!reader) {
return null;
}
const { done, value } = await reader.read();
if (done) {
blobReaders.delete(id);
releaseReader(reader);
return null;
}
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
const size = Math.max(1, Number(chunkSize) || chunk.byteLength);
if (chunk.byteLength <= size) {
return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
}
const head = chunk.slice(0, size);
let offset = size;
blobReaders.set(id, {
read() {
if (offset >= chunk.byteLength) {
return reader.read();
}
const next = chunk.slice(offset, Math.min(offset + size, chunk.byteLength));
offset += next.byteLength;
return Promise.resolve({ done: false, value: next });
},
cancel(reason) {
return reader.cancel(reason);
},
releaseLock() {
return reader.releaseLock();
}
});
return head.buffer.slice(head.byteOffset, head.byteOffset + head.byteLength);
};
globalThis.__gopeed_blob_close_source = function (id) {
const reader = blobReaders.get(id);
if (!reader) {
return;
}
blobReaders.delete(id);
releaseReader(reader, "blob request closed");
};
const blobPipeReaderStates = new Map();
function releaseBlobPipeReader(state) {
if (!state || state.released) {
return;
}
state.released = true;
if (state.reader && typeof state.reader.releaseLock === "function") {
try {
state.reader.releaseLock();
} catch (_) {
}
}
if (blobPipeReaderStates.get(state.pipeId) === state) {
blobPipeReaderStates.delete(state.pipeId);
}
}
function finishBlobPipeReader(state, cancel, reason) {
if (!state) {
return Promise.resolve();
}
if (cancel) {
state.cancelRequested = true;
if (reason !== undefined) {
state.cancelReason = reason;
}
}
if (state.cleanupPromise) {
return state.cleanupPromise;
}
if (!state.reader) {
// A cancellation may arrive while the asynchronous opener is pending.
// Keep the state until the reader exists so it can still be canceled.
if (!state.cancelRequested || state.openSettled) {
releaseBlobPipeReader(state);
}
return Promise.resolve();
}
state.cleanupPromise = (async function () {
try {
if (state.cancelRequested && typeof state.reader.cancel === "function") {
await state.reader.cancel(state.cancelReason);
}
} catch (_) {
} finally {
releaseBlobPipeReader(state);
}
})();
return state.cleanupPromise;
}
globalThis.__gopeed_blob_cancel_pipe_source = function (pipeId, reason) {
const state = blobPipeReaderStates.get(pipeId);
if (!state) {
return;
}
// finishBlobPipeReader invokes reader.cancel() synchronously up to its
// first await. Do not return the cleanup Promise: Go only needs to ensure
// cancellation has started before releasing the source session.
finishBlobPipeReader(state, true, reason).catch(function () {});
};
globalThis.__gopeed_blob_pipe_source = function (openReadable, request, pipeId) {
if (typeof openReadable !== "function") {
throw new TypeError("blob opener must be callable");
}
const state = {
pipeId,
reader: null,
cancelRequested: false,
cancelReason: "blob pipe closed",
cleanupPromise: null,
openSettled: false,
released: false,
};
blobPipeReaderStates.set(pipeId, state);
const startPipe = async function () {
try {
const source = await openReadable(normalizeOpenRequest(request));
state.openSettled = true;
state.reader = toReadableStreamReader(source, "gopeed.runtime.blob.createObjectURL opener function");
if (state.cancelRequested) {
await finishBlobPipeReader(state, true, state.cancelReason);
return;
}
while (true) {
const { done, value } = await state.reader.read();
if (done) {
globalThis.__gopeed_blob_pipe_close(pipeId);
return;
}
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
const buffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
if (!globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) {
state.cancelRequested = true;
state.cancelReason = "blob pipe closed";
return;
}
await yieldBlobPipeTask();
}
} catch (error) {
if (!state.cancelRequested) {
globalThis.__gopeed_blob_pipe_error(pipeId, error && error.stack ? error.stack : String(error));
}
} finally {
state.openSettled = true;
await finishBlobPipeReader(state, state.cancelRequested, state.cancelReason);
}
};
if (typeof setTimeout === "function") {
setTimeout(startPipe, 0);
} else {
Promise.resolve().then(startPipe);
}
};
globalThis.__gopeed_blob_create_object_url = function (value, options) {
const described = describeObjectURLValue(value);
if (described.kind === "blob") {
return createBlobObjectURL(described.value._buffer, described.value.type || "");
}
if (described.kind === "readable") {
const url = createWritableObjectURL(false);
startReadableObjectURL(url, {
initialReadable: described.initialReadable,
openReadable: null,
sourceLabel: described.sourceLabel,
offset: 0,
reader: null,
cancelled: false,
});
return url;
const blob = described.value;
const normalized = normalizeBlobObjectURLOptions(blob, options, Number(blob.size) > 0);
validateBlobObjectURLOptions(normalized);
const opener = async (request) => openBlobReadable(blob, request);
return createBlobObjectURL(opener, normalized);
}
if (described.kind === "opener") {
const url = createWritableObjectURL(true);
resumableReadableObjectURLs.set(url, described.openReadable);
return url;
const normalized = normalizeBlobObjectURLOptions(null, options, false);
validateBlobObjectURLOptions(normalized);
return createBlobObjectURL(described.openReadable, normalized);
}
throw new TypeError("Unsupported object type for URL.createObjectURL: " + getValueTypeName(described.value) + ". Expected Blob, ReadableStream, or opener function");
throw new TypeError("Unsupported object type for gopeed.runtime.blob.createObjectURL: " + getValueTypeName(described.value) + ". Expected Blob or opener function");
};
URL.revokeObjectURL = function (url) {
if (typeof url === "string" && url.indexOf("gblob:") === 0) {
resumableReadableObjectURLs.delete(url);
releaseActiveReadable(url, "gblob source revoked");
globalThis.__gopeed_blob_revoke_object_url = function (url) {
if (typeof url === "string") {
revokeObjectURL(url);
return;
}
if (originalRevokeObjectURL) {
originalRevokeObjectURL(url);
}
};
@@ -823,7 +913,7 @@
});
let meta;
try {
meta = fetchOpen({
meta = await fetchOpen({
url: request.url,
method: request.method,
headers,
@@ -835,10 +925,10 @@
throw error instanceof Error ? error : new TypeError(String(error));
}
const stream = new ReadableStream({
pull(controller) {
async pull(controller) {
let chunk;
try {
chunk = fetchRead(meta.id, 64 * 1024);
chunk = await fetchRead(meta.id, 64 * 1024);
} catch (error) {
fetchClose(meta.id);
controller.error(error instanceof Error ? error : new TypeError(String(error)));
+19 -29
View File
@@ -271,7 +271,6 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
}
ensureResourceRequestRawURLs(req, ctx.Res)
ctx.Res.CalcSize(nil)
d.applyGBlobResourceMetadata(ctx.Res)
}
res = ctx.Res
},
@@ -279,25 +278,12 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
return
}
func (d *Downloader) applyGBlobResourceMetadata(res *base.Resource) {
if d.gblob == nil || res == nil {
return
}
for _, file := range res.Files {
if file == nil || file.Req == nil {
continue
}
_ = d.gblob.SetSize(file.Req.URL, file.Size)
_ = d.gblob.SetRange(file.Req.URL, res.Range)
}
}
func (d *Downloader) triggerOnStart(task *Task) {
doTrigger(d,
EventOnStart,
task.Meta.Req,
&OnStartContext{
Task: NewExtensionTask(d, task),
Task: newOnStartExtensionTask(task),
},
func(ext *Extension, gopeed *Instance, ctx *OnStartContext) {
// Validate request structure
@@ -309,7 +295,6 @@ func (d *Downloader) triggerOnStart(task *Task) {
}
},
)
return
}
func (d *Downloader) triggerOnError(task *Task, err error) {
@@ -317,7 +302,7 @@ func (d *Downloader) triggerOnError(task *Task, err error) {
EventOnError,
task.Meta.Req,
&OnErrorContext{
Task: NewExtensionTask(d, task),
Task: newOnErrorExtensionTask(d, task),
Error: err,
},
nil,
@@ -328,8 +313,8 @@ func (d *Downloader) triggerOnDone(task *Task) {
doTrigger(d,
EventOnDone,
task.Meta.Req,
&OnErrorContext{
Task: NewExtensionTask(d, task),
&OnDoneContext{
Task: newOnDoneExtensionTask(task),
},
nil,
)
@@ -720,7 +705,7 @@ type OnResolveContext struct {
}
type OnStartContext struct {
Task *ExtensionTask `json:"task"`
Task *Task `json:"task"`
}
type OnErrorContext struct {
@@ -741,28 +726,33 @@ type ExtensionTask struct {
*Task
}
func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask {
// restricts extension scripts to only modify request info
func cloneExtensionTask(task *Task) *Task {
newTask := task.clone()
newTask.Meta.Req = task.Meta.Req
return newTask
}
func newOnStartExtensionTask(task *Task) *Task {
return cloneExtensionTask(task)
}
func newOnErrorExtensionTask(download *Downloader, task *Task) *ExtensionTask {
return &ExtensionTask{
download: download,
Task: newTask,
Task: cloneExtensionTask(task),
}
}
func newOnDoneExtensionTask(task *Task) *Task {
return cloneExtensionTask(task)
}
func (t *ExtensionTask) Continue() error {
return t.download.Continue(&TaskFilter{
IDs: []string{t.ID},
})
}
func (t *ExtensionTask) Pause() error {
return t.download.Pause(&TaskFilter{
IDs: []string{t.ID},
})
}
func parseSettings(settings []*Setting) map[string]any {
m := make(map[string]any)
for _, s := range settings {
+19 -46
View File
@@ -1,14 +1,14 @@
package download
import (
"fmt"
"context"
"io"
"sync"
"github.com/GopeedLab/gopeed/internal/protocol/gblob"
internalblob "github.com/GopeedLab/gopeed/internal/blob"
"github.com/GopeedLab/gopeed/pkg/download/engine"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
"github.com/dop251/goja"
)
type engineSession struct {
@@ -96,57 +96,30 @@ func (s *engineSession) runClosers() {
}
}
func normalizeStreamChunk(data any) ([]byte, error) {
switch v := data.(type) {
case nil:
return nil, nil
case string:
return []byte(v), nil
case []byte:
return v, nil
case goja.ArrayBuffer:
return v.Bytes(), nil
default:
return nil, fmt.Errorf("unsupported stream chunk type: %T", data)
}
}
func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
session := newEngineSession(nil)
engineCfg := &stream.Config{
CreateBlobObjectURL: func(data []byte, contentType string) (string, error) {
return d.gblob.CreateBlob(data, contentType)
},
CreateWritableStreamObjectURL: func(opts *stream.WritableStreamObjectURLOptions) (string, error) {
reopenable := false
CreateObjectURL: func(opts *stream.ObjectURLOptions, open stream.ObjectURLOpener) (string, error) {
createOpts := &internalblob.CreateOptions{
Session: session,
}
if opts != nil {
reopenable = opts.Reopenable
createOpts.ContentType = opts.ContentType
createOpts.Size = opts.Size
createOpts.Range = opts.Range
}
return d.gblob.CreateWritableStream(&gblob.CreateWritableStreamOptions{
Session: session,
Reopenable: reopenable,
})
},
RegisterWritableStreamResume: func(url string, reopen func(offset int64) error) error {
return d.gblob.SetResumeOpener(url, reopen)
},
WriteWritableStreamObjectURL: func(url string, data any) error {
chunk, err := normalizeStreamChunk(data)
if err != nil {
return err
}
return d.gblob.Write(url, chunk)
},
CloseWritableStreamObjectURL: func(url string) error {
return d.gblob.CloseSource(url)
},
AbortWritableStreamObjectURL: func(url string, reason string) error {
return d.gblob.AbortSource(url, fmt.Errorf("%s", reason))
return d.blob.CreateOpener(func(ctx context.Context, req internalblob.OpenRequest) (io.ReadCloser, error) {
return open(ctx, stream.ObjectURLOpenRequest{
Offset: req.Offset,
End: req.End,
})
}, createOpts)
},
RevokeObjectURL: func(url string) error {
return d.gblob.Revoke(url)
return d.blob.Revoke(url)
},
ProxyHandler: d.cfg.Proxy.ToHandler(),
ProxyHandler: d.cfg.Proxy.ToHandler(),
RegisterCleanup: session.OnClose,
}
e := engine.NewEngine(&engine.Config{
ProxyConfig: d.cfg.Proxy,
+33 -2
View File
@@ -29,10 +29,15 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance) error {
return err
}
runtimeObject := vm.NewObject()
if gopeed.Runtime != nil && gopeed.Runtime.WebView != nil {
if err := runtimeObject.Set("webview", newJSWebViewRuntime(vm, gopeed.Runtime.WebView)); err != nil {
if gopeed.Runtime != nil {
if err := runtimeObject.Set("blob", newJSBlobRuntime(vm)); err != nil {
return err
}
if gopeed.Runtime.WebView != nil {
if err := runtimeObject.Set("webview", newJSWebViewRuntime(vm, gopeed.Runtime.WebView)); err != nil {
return err
}
}
}
if err := gopeedObject.Set("runtime", runtimeObject); err != nil {
return err
@@ -40,6 +45,32 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance) error {
return vm.Set("gopeed", gopeedObject)
}
func newJSBlobRuntime(vm *goja.Runtime) *goja.Object {
obj := vm.NewObject()
_ = obj.Set("createObjectURL", func(call goja.FunctionCall) goja.Value {
fn, ok := goja.AssertFunction(vm.Get("__gopeed_blob_create_object_url"))
if !ok {
panic(vm.ToValue(fmt.Errorf("blob runtime is not available")))
}
value, err := fn(goja.Undefined(), call.Argument(0), call.Argument(1))
if err != nil {
panic(vm.ToValue(err))
}
return value
})
_ = obj.Set("revokeObjectURL", func(call goja.FunctionCall) goja.Value {
fn, ok := goja.AssertFunction(vm.Get("__gopeed_blob_revoke_object_url"))
if !ok {
panic(vm.ToValue(fmt.Errorf("blob runtime is not available")))
}
if _, err := fn(goja.Undefined(), call.Argument(0)); err != nil {
panic(vm.ToValue(err))
}
return goja.Undefined()
})
return obj
}
func newJSEventsRuntime(vm *goja.Runtime, events InstanceEvents) *goja.Object {
obj := vm.NewObject()
register := func(event ActivationEvent, call goja.FunctionCall) goja.Value {
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -9,7 +9,6 @@ import (
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/protocol/bt"
"github.com/GopeedLab/gopeed/internal/protocol/ed2k"
"github.com/GopeedLab/gopeed/internal/protocol/gblob"
"github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
@@ -37,6 +36,9 @@ type Task struct {
timer *util.Timer
statusLock *sync.Mutex
lock *sync.Mutex
blobRefLock *sync.Mutex
blobURL string
runGeneration uint64
speedArr []int64
uploadSpeedArr []int64
}
@@ -166,7 +168,6 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig {
}
if len(cfg.FetchManagers) == 0 {
cfg.FetchManagers = []fetcher.FetcherManager{
new(gblob.FetcherManager),
new(http.FetcherManager),
new(bt.FetcherManager),
new(ed2k.FetcherManager),
+111
View File
@@ -0,0 +1,111 @@
gopeed.events.onResolve(async function (ctx) {
const encoder = new TextEncoder();
if (ctx.req.url.endsWith("/blob")) {
const blob = new Blob(["hello world"], {type: "text/plain"});
const url = gopeed.runtime.blob.createObjectURL(blob);
ctx.res = {
name: "blob-blob",
files: [
{
name: "hello.txt",
size: 11,
req: {
url,
}
}
]
};
return;
}
if (ctx.req.url.endsWith("/opener-range")) {
const payload = encoder.encode("line 1\nline 2\nline 3\n");
const firstChunkLength = encoder.encode("line 1\n").byteLength;
let firstAttempt = true;
const open = async ({ offset = 0, end = -1 }) => new ReadableStream({
async start(controller) {
try {
if (offset === 0 && firstAttempt) {
firstAttempt = false;
controller.enqueue(payload.slice(0, firstChunkLength));
await new Promise((resolve) => setTimeout(resolve, 120));
controller.error(new Error("resume required"));
return;
}
controller.enqueue(payload.slice(offset, end >= 0 ? end + 1 : undefined));
controller.close();
} catch (err) {
controller.error(err);
}
}
});
const url = gopeed.runtime.blob.createObjectURL(open, { size: payload.byteLength, range: true });
ctx.res = {
name: "blob-opener-range",
range: true,
files: [
{
name: "opener-range.txt",
size: payload.byteLength,
req: {
url,
}
}
]
};
return;
}
if (ctx.req.url.endsWith("/opener-unknown")) {
const url = gopeed.runtime.blob.createObjectURL(async ({ offset = 0 }) => new ReadableStream({
async start(controller) {
if (offset > 0) {
controller.close();
return;
}
controller.enqueue(encoder.encode("line 1\n"));
await new Promise((resolve) => setTimeout(resolve, 1500));
controller.enqueue(encoder.encode("line 2\n"));
controller.close();
},
}));
ctx.res = {
name: "blob-opener-unknown",
files: [
{
name: "opener-unknown.txt",
req: {
url,
}
}
]
};
return;
}
const url = gopeed.runtime.blob.createObjectURL(async ({ offset = 0, end = -1 }) => new ReadableStream({
start(controller) {
const payload = encoder.encode("line 1\nline 2\n");
controller.enqueue(payload.slice(offset, end >= 0 ? end + 1 : undefined));
controller.close();
},
}), { size: 14 });
ctx.res = {
name: "blob-opener",
files: [
{
name: "opener.txt",
size: 14,
req: {
url,
}
}
]
};
});
@@ -1,6 +1,6 @@
{
"name": "gblob",
"title": "gopeed extension gblob test",
"name": "blob",
"title": "gopeed extension blob test",
"version": "0.0.1",
"scripts": [
{
@@ -0,0 +1,140 @@
const payloadSize = 262144;
const chunkSize = 4096;
const payloadText = 'x'.repeat(payloadSize);
function delayedPayloadStream(offset = 0, end = -1) {
const limit = end >= 0 ? Math.min(end + 1, payloadSize) : payloadSize;
let pos = offset;
return new ReadableStream({
async pull(controller) {
if (pos >= limit) {
controller.close();
return;
}
const size = Math.min(chunkSize, limit - pos);
const chunk = new Uint8Array(size);
chunk.fill(120);
pos += size;
controller.enqueue(chunk);
await new Promise((resolve) => setTimeout(resolve, 20));
},
});
}
function fastPayloadStream(offset = 0, end = -1) {
const limit = end >= 0 ? Math.min(end + 1, payloadSize) : payloadSize;
let pos = offset;
return new ReadableStream({
pull(controller) {
if (pos >= limit) {
controller.close();
return;
}
const size = Math.min(chunkSize, limit - pos);
const chunk = new Uint8Array(size);
chunk.fill(120);
pos += size;
controller.enqueue(chunk);
},
});
}
function createBlobSourceURL(delayed) {
const blob = new Blob([payloadText], { type: 'application/octet-stream' });
const originalSlice = blob.slice.bind(blob);
blob.slice = function (offset = 0, end) {
if (!delayed) {
return originalSlice(offset, end);
}
const sliceEnd = Number.isFinite(Number(end)) ? Number(end) - 1 : -1;
return {
stream() {
return delayedPayloadStream(Number(offset) || 0, sliceEnd);
},
};
};
return gopeed.runtime.blob.createObjectURL(blob);
}
function createOpenerSourceURL(delayed) {
const open = async () => delayed ? delayedPayloadStream(0, -1) : fastPayloadStream(0, -1);
return gopeed.runtime.blob.createObjectURL(open, {
size: payloadSize,
contentType: 'application/octet-stream',
});
}
function createRangeSourceURL(delayed) {
const open = async ({ offset = 0, end = -1 }) => delayed ? delayedPayloadStream(offset, end) : fastPayloadStream(offset, end);
return gopeed.runtime.blob.createObjectURL(open, {
size: payloadSize,
range: true,
contentType: 'application/octet-stream',
});
}
function modeFromURL(url) {
if (url.includes('/pause-rebuild-blob')) {
return 'blob';
}
if (url.includes('/pause-rebuild-opener')) {
return 'opener';
}
if (url.includes('/pause-rebuild-range')) {
return 'range';
}
return '';
}
function createPayloadURL(mode, delayed) {
switch (mode) {
case 'blob':
return createBlobSourceURL(delayed);
case 'opener':
return createOpenerSourceURL(delayed);
case 'range':
return createRangeSourceURL(delayed);
default:
throw new Error('unsupported pause rebuild mode: ' + mode);
}
}
gopeed.events.onResolve(async function (ctx) {
const mode = modeFromURL(ctx.req.url);
if (!mode) {
return;
}
ctx.res = {
name: 'blob-pause-rebuild-' + mode,
range: mode !== 'opener',
files: [
{
name: 'pause-rebuild-' + mode + '.bin',
size: payloadSize,
req: {
url: createPayloadURL(mode, true),
rawUrl: ctx.req.rawUrl || ctx.req.url,
labels: {
mode: 'pause-rebuild',
source: mode,
},
},
},
],
};
});
gopeed.events.onError(async function (ctx) {
const req = ctx.task?.meta?.req;
const source = req?.labels?.source || modeFromURL(req?.rawUrl || '');
if (!req || req.labels?.mode !== 'pause-rebuild' || req.labels?.rebuilt === 'true' || !source) {
return;
}
req.url = createPayloadURL(source, false);
req.labels = req.labels || {};
req.labels.started = 'true';
req.labels.rebuilt = 'true';
ctx.task.continue();
});
@@ -0,0 +1,28 @@
{
"name": "blob-pause-rebuild",
"title": "gopeed extension blob pause rebuild test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://example.com/pause-rebuild*"
]
},
"entry": "index.js"
},
{
"event": "onError",
"match": {
"labels": [
"mode"
],
"urls": [
"*://example.com/pause-rebuild*"
]
},
"entry": "index.js"
}
]
}
+46
View File
@@ -0,0 +1,46 @@
gopeed.events.onResolve(async function (ctx) {
const isRange = ctx.req.url.endsWith("/recover-range");
if (!isRange && !ctx.req.url.endsWith("/recover")) {
return;
}
const url = gopeed.runtime.blob.createObjectURL(async ({ offset = 0 }) => new ReadableStream({
async start(controller) {
if (offset > 0) {
controller.close();
return;
}
controller.enqueue(new TextEncoder().encode("stale-prefix-that-must-be-removed\n"));
await new Promise((resolve) => setTimeout(resolve, 50));
controller.error(new Error("expired"));
},
}), { size: 64, range: isRange });
ctx.res = {
name: "blob-recover",
files: [
{
name: "recover.txt",
size: 64,
req: {
url,
}
}
]
};
});
gopeed.events.onError(async function (ctx) {
const req = ctx.task.meta.req;
if (!req.rawUrl || (!req.rawUrl.endsWith("/recover") && !req.rawUrl.endsWith("/recover-range"))) {
return;
}
req.labels = req.labels || {};
if (req.labels.recovered === "true") {
return;
}
req.labels.recovered = "true";
req.url = gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" }));
ctx.task.continue();
});
@@ -1,6 +1,6 @@
{
"name": "gblob-recover",
"title": "gopeed extension gblob recover test",
"name": "blob-recover",
"title": "gopeed extension blob recover test",
"version": "0.0.1",
"scripts": [
{
@@ -1,15 +1,15 @@
function createRangeGBlobUrl(target) {
return URL.createObjectURL(async (offset) => {
function createRangeBlobUrl(target) {
return gopeed.runtime.blob.createObjectURL(async ({ offset = 0, end = -1 }) => {
const headers = {};
if (offset > 0) {
headers.Range = `bytes=${offset}-`;
if (offset > 0 || end >= 0) {
headers.Range = `bytes=${offset}-${end >= 0 ? end : ''}`;
}
const response = await fetch(target, { headers });
if (!response.body) {
throw new Error('empty response body');
}
return response.body;
});
}, { size: 262144, range: true });
}
gopeed.events.onResolve(async function (ctx) {
@@ -18,14 +18,14 @@ gopeed.events.onResolve(async function (ctx) {
}
ctx.res = {
name: 'gblob-restart',
name: 'blob-restart',
range: true,
files: [
{
name: 'restart.bin',
size: 262144,
req: {
url: createRangeGBlobUrl(ctx.req.rawUrl || ctx.req.url),
url: createRangeBlobUrl(ctx.req.rawUrl || ctx.req.url),
rawUrl: ctx.req.rawUrl || ctx.req.url,
labels: {
mode: 'restart',
@@ -38,7 +38,7 @@ gopeed.events.onResolve(async function (ctx) {
gopeed.events.onError(async function (ctx) {
const req = ctx.task?.meta?.req;
if (!req || !req.rawUrl || !req.url || !req.url.startsWith('gblob:')) {
if (!req || !req.rawUrl || !req.url || !req.url.includes('/__blob/')) {
return;
}
req.labels = req.labels || {};
@@ -47,7 +47,7 @@ gopeed.events.onError(async function (ctx) {
}
try {
req.url = createRangeGBlobUrl(req.rawUrl);
req.url = createRangeBlobUrl(req.rawUrl);
req.labels.started = 'true';
req.labels.rebuilt = 'true';
ctx.task.continue();
@@ -1,6 +1,6 @@
{
"name": "gblob-restart",
"title": "gopeed extension gblob restart resume test",
"name": "blob-restart",
"title": "gopeed extension blob restart resume test",
"version": "0.0.1",
"scripts": [
{
@@ -0,0 +1,198 @@
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function byteLength(value) {
return encoder.encode(value).byteLength;
}
function sourceURL(rawURL) {
const url = new URL(rawURL);
url.searchParams.set("source", "1");
return url.toString();
}
function createFetchDrainedStream(url) {
return new ReadableStream({
async start(controller) {
try {
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(value);
}
} catch (error) {
controller.error(error);
}
},
});
}
function createFetchDrainedRangeStream(url, offset, end) {
return new ReadableStream({
async start(controller) {
try {
const headers = {};
if (offset > 0 || end >= 0) {
headers.Range = `bytes=${offset}-${end >= 0 ? end : ""}`;
}
const response = await fetch(url, { headers });
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(value);
}
} catch (error) {
controller.error(error);
}
},
});
}
function createMultiplexStreams(url) {
let videoController;
let audioController;
let started = false;
async function pump() {
if (started || !videoController || !audioController) {
return;
}
started = true;
try {
const response = await fetch(url);
const reader = response.body.getReader();
let pending = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
pending += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = pending.indexOf("\n")) >= 0) {
const line = pending.slice(0, newlineIndex);
pending = pending.slice(newlineIndex + 1);
if (line.startsWith("v:")) {
videoController.enqueue(encoder.encode(line.slice(2)));
} else if (line.startsWith("a:")) {
audioController.enqueue(encoder.encode(line.slice(2)));
}
}
}
if (pending.startsWith("v:")) {
videoController.enqueue(encoder.encode(pending.slice(2)));
} else if (pending.startsWith("a:")) {
audioController.enqueue(encoder.encode(pending.slice(2)));
}
videoController.close();
audioController.close();
} catch (error) {
videoController.error(error);
audioController.error(error);
}
}
return {
video: new ReadableStream({
start(controller) {
videoController = controller;
pump();
},
}),
audio: new ReadableStream({
start(controller) {
audioController = controller;
pump();
},
}),
};
}
gopeed.events.onResolve(async function (ctx) {
if (ctx.req.url.includes("/single-fetch-stream")) {
const payloadSize = Number(new URL(ctx.req.url).searchParams.get("size")) || 0;
const url = gopeed.runtime.blob.createObjectURL(async () => createFetchDrainedStream(sourceURL(ctx.req.url)), {
size: payloadSize,
});
ctx.res = {
name: "single-fetch-stream",
files: [
{
name: "single.bin",
size: payloadSize,
req: { url },
},
],
};
return;
}
if (ctx.req.url.includes("/range-fetch-stream")) {
const payloadSize = Number(new URL(ctx.req.url).searchParams.get("size")) || 0;
const url = gopeed.runtime.blob.createObjectURL(
async ({ offset = 0, end = -1 }) => createFetchDrainedRangeStream(sourceURL(ctx.req.url), offset, end),
{
size: payloadSize,
range: true,
}
);
ctx.res = {
name: "range-fetch-stream",
range: true,
files: [
{
name: "range.bin",
size: payloadSize,
req: { url },
},
],
};
return;
}
if (ctx.req.url.includes("/multiplex-fetch-stream")) {
const videoChunks = ["video-0", "video-1", "video-2", "video-3"];
const audioChunks = ["audio-0", "audio-1", "audio-2", "audio-3"];
let streams;
const openStreams = () => {
if (!streams) {
streams = createMultiplexStreams(sourceURL(ctx.req.url));
}
return streams;
};
const videoUrl = gopeed.runtime.blob.createObjectURL(async () => openStreams().video, {
size: videoChunks.reduce((sum, chunk) => sum + byteLength(chunk), 0),
});
const audioUrl = gopeed.runtime.blob.createObjectURL(async () => openStreams().audio, {
size: audioChunks.reduce((sum, chunk) => sum + byteLength(chunk), 0),
});
ctx.res = {
name: "multiplex-fetch-stream",
files: [
{
name: "video.bin",
size: videoChunks.reduce((sum, chunk) => sum + byteLength(chunk), 0),
req: { url: videoUrl },
},
{
name: "audio.bin",
size: audioChunks.reduce((sum, chunk) => sum + byteLength(chunk), 0),
req: { url: audioUrl },
},
],
};
}
});
@@ -0,0 +1,18 @@
{
"name": "blob-stream-bridge",
"title": "gopeed extension blob stream bridge test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://*/single-fetch-stream*",
"*://*/range-fetch-stream*",
"*://*/multiplex-fetch-stream*"
]
},
"entry": "index.js"
}
]
}
-183
View File
@@ -1,183 +0,0 @@
gopeed.events.onResolve(async function (ctx) {
const encoder = new TextEncoder();
if (ctx.req.url.endsWith("/blob")) {
const blob = new Blob(["hello world"], {type: "text/plain"});
const url = URL.createObjectURL(blob);
ctx.res = {
name: "gblob-blob",
files: [
{
name: "hello.txt",
size: 11,
req: {
url,
}
}
]
};
return;
}
if (ctx.req.url.includes("/http-stream")) {
const source = new URL(ctx.req.url);
const target = source.searchParams.get("target");
const pair = source.searchParams.get("pair") === "1";
const resumable = ctx.req.url.includes("/http-stream-range");
const fileName = source.searchParams.get("name") || "http-stream.bin";
const headResponse = await fetch(target, {method: "HEAD"});
const headLength = parseInt(headResponse.headers.get("content-length") || "", 10);
const createOpen = () => async (offset) => {
const headers = {};
if (offset > 0) {
headers.Range = `bytes=${offset}-`;
}
const response = await fetch(target, {headers});
if (!response.body) {
throw new Error("empty response body");
}
return response.body;
};
ctx.res = {
name: "gblob-http-stream",
range: resumable,
files: pair ? [
{
name: "pair-video.bin",
size: Number.isFinite(headLength) ? headLength : 0,
req: {
url: URL.createObjectURL(createOpen()),
}
},
{
name: "pair-audio.bin",
size: Number.isFinite(headLength) ? headLength : 0,
req: {
url: URL.createObjectURL(createOpen()),
}
}
] : [
{
name: fileName,
size: Number.isFinite(headLength) ? headLength : 0,
req: {
url: URL.createObjectURL(createOpen()),
}
}
]
};
return;
}
if (ctx.req.url.includes("/http-blob")) {
const source = new URL(ctx.req.url);
const target = source.searchParams.get("target");
const fileName = source.searchParams.get("name") || "http-blob.bin";
const headResponse = await fetch(target, {method: "HEAD"});
const headLength = parseInt(headResponse.headers.get("content-length") || "", 10);
const response = await fetch(target);
const blob = await response.blob();
ctx.res = {
name: "gblob-http-blob",
files: [
{
name: fileName,
size: Number.isFinite(headLength) ? headLength : 0,
req: {
url: URL.createObjectURL(blob),
}
}
]
};
return;
}
if (ctx.req.url.endsWith("/stream-range")) {
const payload = encoder.encode("line 1\nline 2\nline 3\n");
const firstChunkLength = encoder.encode("line 1\n").byteLength;
let firstAttempt = true;
const open = async (offset) => new ReadableStream({
async start(controller) {
try {
if (offset === 0 && firstAttempt) {
firstAttempt = false;
controller.enqueue(payload.slice(0, firstChunkLength));
await new Promise((resolve) => setTimeout(resolve, 120));
controller.error(new Error("resume required"));
return;
}
controller.enqueue(payload.slice(offset));
controller.close();
} catch (err) {
controller.error(err);
}
}
});
const url = URL.createObjectURL(open);
ctx.res = {
name: "gblob-stream-range",
range: true,
files: [
{
name: "stream-range.txt",
size: payload.byteLength,
req: {
url,
}
}
]
};
return;
}
if (ctx.req.url.endsWith("/stream-unknown")) {
const url = URL.createObjectURL(new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode("line 1\n"));
await new Promise((resolve) => setTimeout(resolve, 350));
controller.enqueue(encoder.encode("line 2\n"));
controller.close();
},
}));
ctx.res = {
name: "gblob-stream-unknown",
files: [
{
name: "stream-unknown.txt",
req: {
url,
}
}
]
};
return;
}
const url = URL.createObjectURL(new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("line 1\n"));
controller.enqueue(encoder.encode("line 2\n"));
controller.close();
},
}));
ctx.res = {
name: "gblob-stream",
files: [
{
name: "stream.txt",
size: 14,
req: {
url,
}
}
]
};
});
-40
View File
@@ -1,40 +0,0 @@
gopeed.events.onResolve(async function (ctx) {
if (!ctx.req.url.endsWith("/recover")) {
return;
}
const url = URL.createObjectURL(new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode("stale\n"));
await new Promise((resolve) => setTimeout(resolve, 50));
controller.error(new Error("expired"));
},
}));
ctx.res = {
name: "gblob-recover",
files: [
{
name: "recover.txt",
req: {
url,
}
}
]
};
});
gopeed.events.onError(async function (ctx) {
const req = ctx.task.meta.req;
if (!req.rawUrl || !req.rawUrl.endsWith("/recover")) {
return;
}
req.labels = req.labels || {};
if (req.labels.recovered === "true") {
return;
}
req.labels.recovered = "true";
req.url = URL.createObjectURL(new Blob(["recovered\n"], { type: "text/plain" }));
ctx.task.continue();
});
+20 -4
View File
@@ -67,6 +67,10 @@ var (
}
)
func currentTestDownloadFile() string {
return filepath.Join(createOpts.Path, createOpts.Name)
}
func TestInfo(t *testing.T) {
matchKeys := []string{"version", "runtime", "os", "arch", "inDocker"}
doTest(func() {
@@ -109,7 +113,7 @@ func TestCreateTask(t *testing.T) {
wg.Wait()
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
got := test.FileMd5(currentTestDownloadFile())
if want != got {
t.Errorf("CreateTask() got = %v, want %v", got, want)
}
@@ -133,7 +137,7 @@ func TestCreateDirectTask(t *testing.T) {
wg.Wait()
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
got := test.FileMd5(currentTestDownloadFile())
if want != got {
t.Errorf("CreateDirectTask() got = %v, want %v", got, want)
}
@@ -226,7 +230,7 @@ func TestPauseAndContinueTask(t *testing.T) {
wg.Wait()
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
got := test.FileMd5(currentTestDownloadFile())
if !reflect.DeepEqual(got, want) {
t.Errorf("PauseAndContinueTask() got = %v, want %v", got, want)
}
@@ -344,7 +348,7 @@ func TestDeleteTaskForce(t *testing.T) {
httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil)
code, _ := httpRequest[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
checkCode(code, model.CodeTaskNotFound)
if _, err := os.Stat(test.DownloadFile); !errors.Is(err, os.ErrNotExist) {
if _, err := os.Stat(currentTestDownloadFile()); !errors.Is(err, os.ErrNotExist) {
t.Errorf("DeleteTaskForce() got = %v, want %v", err, os.ErrNotExist)
}
})
@@ -935,10 +939,22 @@ func doTest(handler func()) {
func doTest0(onStart func(cfg *model.StartConfig), handler func()) {
testFunc := func(storage model.Storage) {
downloadDir, err := os.MkdirTemp("", "gopeed-rest-test-download-")
if err != nil {
panic(err)
}
defer os.RemoveAll(downloadDir)
oldCreatePath := createOpts.Path
createOpts.Path = downloadDir
defer func() {
createOpts.Path = oldCreatePath
}()
var cfg = &model.StartConfig{}
cfg.Init()
cfg.Storage = storage
cfg.StorageDir = ".test_storage"
cfg.DownloadConfig = (&base.DownloaderStoreConfig{DownloadDir: downloadDir}).Init()
cfg.WebEnable = true
if onStart != nil {
onStart(cfg)
+1 -1
View File
@@ -6,7 +6,7 @@ part 'task.g.dart';
enum Status { ready, running, pause, wait, error, done }
enum Protocol { http, bt, ed2k, gblob }
enum Protocol { http, bt, ed2k }
// ExtractStatus enum matching Go backend
enum ExtractStatus {
-1
View File
@@ -52,7 +52,6 @@ const _$ProtocolEnumMap = {
Protocol.http: 'http',
Protocol.bt: 'bt',
Protocol.ed2k: 'ed2k',
Protocol.gblob: 'gblob',
};
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(