1858 lines
54 KiB
Go
1858 lines
54 KiB
Go
package downloader_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"softbox.local/core/downloader"
|
|
"softbox.local/core/storage"
|
|
)
|
|
|
|
func TestQueueDefaultConcurrencyDoesNotExceedTwo(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := newBlockingTransport([]byte("data"))
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
ProgressEvery: time.Hour,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
|
|
urls := make([]string, 5)
|
|
for index := range urls {
|
|
urls[index] = "https://download.invalid/request-" + string(rune('a'+index))
|
|
_, err := queue.Enqueue(context.Background(), downloader.EnqueueRequest{
|
|
RequestID: "request-" + string(rune('a'+index)),
|
|
AppID: "app-" + string(rune('a'+index)),
|
|
URL: urls[index],
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue(%d) error = %v", index, err)
|
|
}
|
|
}
|
|
|
|
first := waitOpen(t, transport.opens)
|
|
second := waitOpen(t, transport.opens)
|
|
select {
|
|
case third := <-transport.opens:
|
|
t.Fatalf("third transfer started early: %#v", third)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
if transport.maxActiveValue() != 2 {
|
|
t.Fatalf("max active = %d, want 2", transport.maxActiveValue())
|
|
}
|
|
|
|
transport.release(first.URL)
|
|
_ = waitOpen(t, transport.opens)
|
|
transport.release(second.URL)
|
|
}
|
|
|
|
func TestQueueActivePauseStartsNextQueuedTask(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := newBlockingTransport([]byte("data"))
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: newEventObserver(),
|
|
MaxConcurrent: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
for _, suffix := range []string{"a", "b", "c"} {
|
|
_, err := queue.Enqueue(context.Background(), downloader.EnqueueRequest{
|
|
RequestID: "active-pause-" + suffix,
|
|
AppID: "active-pause-app-" + suffix,
|
|
URL: "https://download.invalid/active-pause-" + suffix,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue(%s) error = %v", suffix, err)
|
|
}
|
|
}
|
|
first := waitOpen(t, transport.opens)
|
|
second := waitOpen(t, transport.opens)
|
|
initialURLs := map[string]bool{
|
|
first.URL: true,
|
|
second.URL: true,
|
|
}
|
|
if !initialURLs["https://download.invalid/active-pause-a"] ||
|
|
!initialURLs["https://download.invalid/active-pause-b"] ||
|
|
len(initialURLs) != 2 {
|
|
t.Fatalf("initial opens = %#v, %#v", first, second)
|
|
}
|
|
if err := queue.Pause(context.Background(), "active-pause-a"); err != nil {
|
|
t.Fatalf("Pause(active) error = %v", err)
|
|
}
|
|
next := waitOpen(t, transport.opens)
|
|
if next.URL != "https://download.invalid/active-pause-c" {
|
|
t.Fatalf("next URL = %q, want queued task", next.URL)
|
|
}
|
|
paused, found := queue.Snapshot("active-pause-a")
|
|
if !found || paused.Status != downloader.StatusPaused {
|
|
t.Fatalf("paused task = %#v, found=%t", paused, found)
|
|
}
|
|
if transport.maxActiveValue() > 2 {
|
|
t.Fatalf("max active = %d, want <= 2", transport.maxActiveValue())
|
|
}
|
|
}
|
|
|
|
func TestQueuePauseReleasesSlotAndQueuedPauseDoesNotStart(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := newBlockingTransport([]byte("data"))
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: newEventObserver(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
for _, suffix := range []string{"a", "b", "c"} {
|
|
_, err := queue.Enqueue(context.Background(), downloader.EnqueueRequest{
|
|
RequestID: "pause-" + suffix,
|
|
AppID: "pause-app-" + suffix,
|
|
URL: "https://download.invalid/pause-" + suffix,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue(%s) error = %v", suffix, err)
|
|
}
|
|
}
|
|
_ = waitOpen(t, transport.opens)
|
|
_ = waitOpen(t, transport.opens)
|
|
if err := queue.Pause(context.Background(), "pause-c"); err != nil {
|
|
t.Fatalf("Pause(queued) error = %v", err)
|
|
}
|
|
if err := queue.Pause(context.Background(), "pause-a"); err != nil {
|
|
t.Fatalf("Pause(active) error = %v", err)
|
|
}
|
|
select {
|
|
case request := <-transport.opens:
|
|
t.Fatalf("paused queued task started: %#v", request)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
if err := queue.Resume(context.Background(), "pause-c"); err != nil {
|
|
t.Fatalf("Resume() error = %v", err)
|
|
}
|
|
started := waitOpen(t, transport.opens)
|
|
if started.URL != "https://download.invalid/pause-c" {
|
|
t.Fatalf("started URL = %q", started.URL)
|
|
}
|
|
}
|
|
|
|
func TestQueuePauseResumeUsesDiskOffsetAndValidator(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := &resumeTransport{}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
Clock: newAdvancingClock(),
|
|
ProgressEvery: downloader.DefaultProgressPeriod,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
|
|
_, err = queue.Enqueue(context.Background(), downloader.EnqueueRequest{
|
|
RequestID: "request-resume",
|
|
AppID: "app-resume",
|
|
URL: "https://download.invalid/resume",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventProgress, "request-resume")
|
|
if err := queue.Pause(context.Background(), "request-resume"); err != nil {
|
|
t.Fatalf("Pause() error = %v", err)
|
|
}
|
|
task, found := queue.Snapshot("request-resume")
|
|
if !found || task.Status != downloader.StatusPaused || task.Done != 2 {
|
|
t.Fatalf("paused task = %#v, found=%t", task, found)
|
|
}
|
|
assertNoEvent(t, observer.events, downloader.EventProgress, "request-resume")
|
|
if err := queue.Resume(context.Background(), "request-resume"); err != nil {
|
|
t.Fatalf("Resume() error = %v", err)
|
|
}
|
|
completed := waitEvent(
|
|
t,
|
|
observer.events,
|
|
downloader.EventCompleted,
|
|
"request-resume",
|
|
)
|
|
if completed.Done != 4 {
|
|
t.Fatalf("completed done = %d, want 4", completed.Done)
|
|
}
|
|
opens := transport.openRequests()
|
|
if len(opens) != 2 ||
|
|
opens[0].Offset != 0 ||
|
|
opens[1].Offset != 2 ||
|
|
opens[1].Validator.ETag != `"v1"` {
|
|
t.Fatalf("open requests = %#v", opens)
|
|
}
|
|
}
|
|
|
|
func TestQueuePauseReportsBodyCloseFailure(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := &closeErrorTransport{
|
|
opened: make(chan struct{}, 1),
|
|
}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-close-error",
|
|
AppID: "app-close-error",
|
|
URL: "https://download.invalid/close-error",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
select {
|
|
case <-transport.opened:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for close-error transfer")
|
|
}
|
|
if err := queue.Pause(context.Background(), request.RequestID); !errors.Is(
|
|
err,
|
|
errInjectedBodyClose,
|
|
) {
|
|
t.Fatalf("Pause() error = %v, want %v", err, errInjectedBodyClose)
|
|
}
|
|
failed, found := queue.Snapshot(request.RequestID)
|
|
if !found || failed.Status != downloader.StatusFailed {
|
|
t.Fatalf("failed task = %#v, found=%t", failed, found)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventFailed, request.RequestID)
|
|
}
|
|
|
|
func TestQueueResumeFinalizesCompletePausedPartWithoutReopening(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := newFullThenBlockTransport([]byte("data"))
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-full-pause",
|
|
AppID: "app-full-pause",
|
|
URL: "https://download.invalid/full-pause",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
transport.waitWritten(t)
|
|
if err := queue.Pause(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Pause() error = %v", err)
|
|
}
|
|
paused, found := queue.Snapshot(request.RequestID)
|
|
if !found || paused.Status != downloader.StatusPaused || paused.Done != 4 {
|
|
t.Fatalf("paused task = %#v, found=%t", paused, found)
|
|
}
|
|
if err := queue.Resume(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Resume() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
if transport.callsValue() != 1 {
|
|
t.Fatalf("transport calls = %d, want 1", transport.callsValue())
|
|
}
|
|
}
|
|
|
|
func TestQueueRetryResumesAfterInterruptedBody(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := &interruptedTransport{}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
Clock: newAdvancingClock(),
|
|
ProgressEvery: downloader.DefaultProgressPeriod,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-interrupted",
|
|
AppID: "app-interrupted",
|
|
URL: "https://download.invalid/interrupted",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventFailed, request.RequestID)
|
|
failed, _ := queue.Snapshot(request.RequestID)
|
|
if failed.Done != 2 {
|
|
t.Fatalf("failed Done = %d, want 2", failed.Done)
|
|
}
|
|
if err := queue.Retry(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Retry() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
opens := transport.openRequests()
|
|
if len(opens) != 2 || opens[1].Offset != 2 ||
|
|
opens[1].Validator.ETag != `"v1"` {
|
|
t.Fatalf("open requests = %#v", opens)
|
|
}
|
|
}
|
|
|
|
func TestQueueRetryFinalizesCompleteFailedPartWithoutReopening(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := &fullThenErrorTransport{data: []byte("data")}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-full-failure",
|
|
AppID: "app-full-failure",
|
|
URL: "https://download.invalid/full-failure",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventFailed, request.RequestID)
|
|
failed, found := queue.Snapshot(request.RequestID)
|
|
if !found || failed.Status != downloader.StatusFailed || failed.Done != 4 {
|
|
t.Fatalf("failed task = %#v, found=%t", failed, found)
|
|
}
|
|
if err := queue.Retry(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Retry() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
if transport.callsValue() != 1 {
|
|
t.Fatalf("transport calls = %d, want 1", transport.callsValue())
|
|
}
|
|
}
|
|
|
|
func TestQueueHTTPConnectionInterruptionResumesWithRange(t *testing.T) {
|
|
content := []byte("data")
|
|
var mu sync.Mutex
|
|
var ranges []string
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
mu.Lock()
|
|
ranges = append(ranges, request.Header.Get("Range"))
|
|
call := len(ranges)
|
|
mu.Unlock()
|
|
writer.Header().Set("ETag", `"v1"`)
|
|
if call == 1 {
|
|
writer.Header().Set("Content-Length", "4")
|
|
_, _ = writer.Write(content[:2])
|
|
return
|
|
}
|
|
if request.Header.Get("Range") != "bytes=2-" ||
|
|
request.Header.Get("If-Range") != `"v1"` {
|
|
t.Errorf(
|
|
"resume headers Range=%q If-Range=%q",
|
|
request.Header.Get("Range"),
|
|
request.Header.Get("If-Range"),
|
|
)
|
|
}
|
|
writer.Header().Set("Content-Range", "bytes 2-3/4")
|
|
writer.Header().Set("Content-Length", "2")
|
|
writer.WriteHeader(http.StatusPartialContent)
|
|
_, _ = writer.Write(content[2:])
|
|
}))
|
|
defer server.Close()
|
|
|
|
root := t.TempDir()
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: downloader.NewHTTPTransport(server.Client()),
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-http-resume",
|
|
AppID: "app-http-resume",
|
|
URL: server.URL,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventFailed, request.RequestID)
|
|
if err := queue.Retry(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Retry() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
paths, _ := downloader.DeriveTaskPaths(root, request.RequestID)
|
|
document, err := os.ReadFile(paths.Completed)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(completed) error = %v", err)
|
|
}
|
|
if !bytes.Equal(document, content) {
|
|
t.Fatalf("completed bytes = %q", document)
|
|
}
|
|
}
|
|
|
|
func TestQueueRangeIgnoredStartsNewAttemptAndReplacesPart(t *testing.T) {
|
|
root := t.TempDir()
|
|
store := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-restart",
|
|
AppID: "app-restart",
|
|
URL: "https://download.invalid/restart",
|
|
Status: downloader.StatusPaused,
|
|
Attempt: 1,
|
|
Done: 2,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := store.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
if err := os.WriteFile(paths.Part, []byte("ab"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
observer := newEventObserver()
|
|
transport := &restartTransport{}
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: store,
|
|
Transport: transport,
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
if err := queue.Resume(context.Background(), task.RequestID); err != nil {
|
|
t.Fatalf("Resume() error = %v", err)
|
|
}
|
|
completed := waitEvent(
|
|
t,
|
|
observer.events,
|
|
downloader.EventCompleted,
|
|
task.RequestID,
|
|
)
|
|
if completed.Attempt != 3 {
|
|
t.Fatalf("completed attempt = %d, want 3", completed.Attempt)
|
|
}
|
|
var startedAttempts []uint64
|
|
for _, event := range observer.snapshot() {
|
|
if event.RequestID == task.RequestID && event.Type == downloader.EventStarted {
|
|
startedAttempts = append(startedAttempts, event.Attempt)
|
|
}
|
|
}
|
|
if len(startedAttempts) != 2 ||
|
|
startedAttempts[0] != 2 ||
|
|
startedAttempts[1] != 3 {
|
|
t.Fatalf("started attempts = %#v, want [2 3]", startedAttempts)
|
|
}
|
|
document, err := os.ReadFile(paths.Completed)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(completed) error = %v", err)
|
|
}
|
|
if string(document) != "WXYZ" {
|
|
t.Fatalf("completed bytes = %q, want WXYZ", document)
|
|
}
|
|
}
|
|
|
|
func TestQueueRestartTruncatesPartBeforePersistingNewAttempt(t *testing.T) {
|
|
root := t.TempDir()
|
|
inner := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-restart-write-failure",
|
|
AppID: "app-restart-write-failure",
|
|
URL: "https://download.invalid/restart-write-failure",
|
|
Status: downloader.StatusPaused,
|
|
Attempt: 1,
|
|
Done: 2,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := inner.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
if err := os.WriteFile(paths.Part, []byte("ab"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: &restartWriteFailStore{
|
|
inner: inner,
|
|
},
|
|
Transport: &restartTransport{},
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
if err := queue.Resume(context.Background(), task.RequestID); err != nil {
|
|
t.Fatalf("Resume() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventFailed, task.RequestID)
|
|
partInfo, err := os.Stat(paths.Part)
|
|
if err != nil {
|
|
t.Fatalf("Stat(part) error = %v", err)
|
|
}
|
|
if partInfo.Size() != 0 {
|
|
t.Fatalf("part size = %d, want 0 before restart metadata write", partInfo.Size())
|
|
}
|
|
if err := queue.Close(context.Background()); err != nil {
|
|
t.Fatalf("Close(first queue) error = %v", err)
|
|
}
|
|
|
|
retryObserver := newEventObserver()
|
|
retryTransport := &recordingFullTransport{data: []byte("WXYZ")}
|
|
recovered, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: inner,
|
|
Transport: retryTransport,
|
|
Observer: retryObserver,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue(recovered) error = %v", err)
|
|
}
|
|
defer recovered.Close(context.Background())
|
|
if err := recovered.Retry(context.Background(), task.RequestID); err != nil {
|
|
t.Fatalf("Retry() error = %v", err)
|
|
}
|
|
waitEvent(t, retryObserver.events, downloader.EventCompleted, task.RequestID)
|
|
opens := retryTransport.openRequests()
|
|
if len(opens) != 1 || opens[0].Offset != 0 {
|
|
t.Fatalf("recovered opens = %#v, want one offset=0 request", opens)
|
|
}
|
|
}
|
|
|
|
func TestQueueCancelCleansActiveTaskAndSuppressesLateProgress(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := newBlockingTransport([]byte("data"))
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
ProgressEvery: 0,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-cancel",
|
|
AppID: "app-cancel",
|
|
URL: "https://download.invalid/cancel",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
_ = waitOpen(t, transport.opens)
|
|
if err := queue.Cancel(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Cancel() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCanceled, request.RequestID)
|
|
if _, found := queue.Snapshot(request.RequestID); found {
|
|
t.Fatal("canceled task still exists")
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, request.RequestID)
|
|
for _, path := range []string{paths.Part, paths.Completed, paths.Metadata} {
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("canceled path still exists %s: %v", path, err)
|
|
}
|
|
}
|
|
assertNoEvent(t, observer.events, downloader.EventProgress, request.RequestID)
|
|
}
|
|
|
|
func TestQueueCancelRemainsSuccessfulWhenObserverFails(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := newBlockingTransport([]byte("data"))
|
|
observer := newFailingEventObserver(downloader.EventCanceled)
|
|
reported := make(chan downloader.Event, 1)
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
OnObserverError: func(event downloader.Event, _ error) {
|
|
reported <- event
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-cancel-observer-failure",
|
|
AppID: "app-cancel-observer-failure",
|
|
URL: "https://download.invalid/cancel-observer-failure",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
_ = waitOpen(t, transport.opens)
|
|
if err := queue.Cancel(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Cancel() error = %v", err)
|
|
}
|
|
if _, found := queue.Snapshot(request.RequestID); found {
|
|
t.Fatal("canceled task still exists after observer failure")
|
|
}
|
|
select {
|
|
case event := <-reported:
|
|
if event.Type != downloader.EventCanceled ||
|
|
event.RequestID != request.RequestID {
|
|
t.Fatalf("reported observer event = %#v", event)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("observer failure was not reported")
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, request.RequestID)
|
|
for _, path := range []string{paths.Part, paths.Completed, paths.Metadata} {
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("canceled path still exists %s: %v", path, err)
|
|
}
|
|
}
|
|
replacement := request
|
|
replacement.RequestID = "request-cancel-observer-replacement"
|
|
replacement.URL = "https://download.invalid/cancel-observer-replacement"
|
|
if _, err := queue.Enqueue(context.Background(), replacement); err != nil {
|
|
t.Fatalf("Enqueue(replacement) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueueCancelCleansTaskDespiteBodyCloseFailure(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := &closeErrorTransport{
|
|
opened: make(chan struct{}, 1),
|
|
}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-cancel-close-error",
|
|
AppID: "app-cancel-close-error",
|
|
URL: "https://download.invalid/cancel-close-error",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
select {
|
|
case <-transport.opened:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for cancel close-error transfer")
|
|
}
|
|
if err := queue.Cancel(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Cancel() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCanceled, request.RequestID)
|
|
if _, found := queue.Snapshot(request.RequestID); found {
|
|
t.Fatal("canceled task still exists")
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, request.RequestID)
|
|
for _, path := range []string{paths.Part, paths.Completed, paths.Metadata} {
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("canceled path still exists %s: %v", path, err)
|
|
}
|
|
}
|
|
assertNoEvent(t, observer.events, downloader.EventFailed, request.RequestID)
|
|
}
|
|
|
|
func TestQueueProgressObserverFailureDoesNotFailTransfer(t *testing.T) {
|
|
root := t.TempDir()
|
|
observer := newFailingEventObserver(downloader.EventProgress)
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: &recordingFullTransport{data: []byte("data")},
|
|
Observer: observer,
|
|
ProgressEvery: 0,
|
|
OnObserverError: func(downloader.Event, error) {
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-progress-observer-failure",
|
|
AppID: "app-progress-observer-failure",
|
|
URL: "https://download.invalid/progress-observer-failure",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
completed, found := queue.Snapshot(request.RequestID)
|
|
if !found || completed.Status != downloader.StatusCompleted {
|
|
t.Fatalf("completed task = %#v, found=%t", completed, found)
|
|
}
|
|
}
|
|
|
|
func TestQueueCompletedObserverFailureIsReportedAndRecoverable(t *testing.T) {
|
|
root := t.TempDir()
|
|
observer := newFailingEventObserver(downloader.EventCompleted)
|
|
reported := make(chan downloader.Event, 1)
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: &recordingFullTransport{data: []byte("data")},
|
|
Observer: observer,
|
|
OnObserverError: func(event downloader.Event, _ error) {
|
|
reported <- event
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-completed-observer-failure",
|
|
AppID: "app-completed-observer-failure",
|
|
URL: "https://download.invalid/completed-observer-failure",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
select {
|
|
case event := <-reported:
|
|
if event.Type != downloader.EventCompleted ||
|
|
event.RequestID != request.RequestID {
|
|
t.Fatalf("reported observer event = %#v", event)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("completed observer failure was not reported")
|
|
}
|
|
tasks := queue.Tasks()
|
|
if len(tasks) != 1 ||
|
|
tasks[0].RequestID != request.RequestID ||
|
|
tasks[0].Status != downloader.StatusCompleted {
|
|
t.Fatalf("durable reconciliation tasks = %#v", tasks)
|
|
}
|
|
}
|
|
|
|
func TestQueueRetryFailedTask(t *testing.T) {
|
|
root := t.TempDir()
|
|
transport := &flakyTransport{}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: transport,
|
|
Observer: observer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-retry",
|
|
AppID: "app-retry",
|
|
URL: "https://download.invalid/retry",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventFailed, request.RequestID)
|
|
if err := queue.Retry(context.Background(), request.RequestID); err != nil {
|
|
t.Fatalf("Retry() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
task, found := queue.Snapshot(request.RequestID)
|
|
if !found || task.Status != downloader.StatusCompleted || task.Attempt != 2 {
|
|
t.Fatalf("retried task = %#v, found=%t", task, found)
|
|
}
|
|
}
|
|
|
|
func TestQueueUnknownProgressIsMonotonicThrottledAndExplicit(t *testing.T) {
|
|
root := t.TempDir()
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: &chunkedUnknownTransport{data: []byte("data")},
|
|
Observer: observer,
|
|
Clock: newAdvancingClock(),
|
|
MaxUnknown: 8,
|
|
ProgressEvery: 3 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-unknown-progress",
|
|
AppID: "app-unknown-progress",
|
|
URL: "https://download.invalid/unknown-progress",
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
waitEvent(t, observer.events, downloader.EventCompleted, request.RequestID)
|
|
var started []downloader.Event
|
|
var progress []downloader.Event
|
|
for _, event := range observer.snapshot() {
|
|
if event.RequestID != request.RequestID {
|
|
continue
|
|
}
|
|
switch event.Type {
|
|
case downloader.EventStarted:
|
|
started = append(started, event)
|
|
case downloader.EventProgress:
|
|
progress = append(progress, event)
|
|
}
|
|
}
|
|
if len(started) != 1 || started[0].TotalKnown || started[0].Total != 0 {
|
|
t.Fatalf("started events = %#v", started)
|
|
}
|
|
if len(progress) != 2 {
|
|
t.Fatalf("progress events = %#v, want throttled [3,4]", progress)
|
|
}
|
|
if progress[0].Done != 3 || progress[1].Done != 4 ||
|
|
progress[0].Attempt != progress[1].Attempt ||
|
|
progress[0].TotalKnown || progress[1].TotalKnown ||
|
|
progress[0].Total != 0 || progress[1].Total != 0 {
|
|
t.Fatalf("progress events = %#v", progress)
|
|
}
|
|
if progress[0].SpeedBytesSec != 1 {
|
|
t.Fatalf("first speed = %d, want 1", progress[0].SpeedBytesSec)
|
|
}
|
|
}
|
|
|
|
func TestQueueUnknownTransferEnforcesResponseAndResumeLimits(t *testing.T) {
|
|
t.Run("fresh response exceeds cap", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: &recordingFullTransport{data: []byte("12345")},
|
|
Observer: observer,
|
|
MaxUnknown: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-unknown-too-large",
|
|
AppID: "app-unknown-too-large",
|
|
URL: "https://download.invalid/unknown-too-large",
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
failed := waitEvent(
|
|
t,
|
|
observer.events,
|
|
downloader.EventFailed,
|
|
request.RequestID,
|
|
)
|
|
if failed.ErrorCode != "transfer_too_large" {
|
|
t.Fatalf("error code = %q", failed.ErrorCode)
|
|
}
|
|
})
|
|
|
|
t.Run("resume requires complete total", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
store := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-unknown-resume",
|
|
AppID: "app-unknown-resume",
|
|
URL: "https://download.invalid/unknown-resume",
|
|
Status: downloader.StatusPaused,
|
|
Attempt: 1,
|
|
Done: 2,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := store.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
if err := os.WriteFile(paths.Part, []byte("ab"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
observer := newEventObserver()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: store,
|
|
Transport: &unknownResumeTransport{},
|
|
Observer: observer,
|
|
MaxUnknown: 8,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
if err := queue.Resume(context.Background(), task.RequestID); err != nil {
|
|
t.Fatalf("Resume() error = %v", err)
|
|
}
|
|
failed := waitEvent(
|
|
t,
|
|
observer.events,
|
|
downloader.EventFailed,
|
|
task.RequestID,
|
|
)
|
|
if failed.ErrorCode != "transfer_incomplete" {
|
|
t.Fatalf("error code = %q", failed.ErrorCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestQueueRecoveryUsesPartLengthAndDowngradesDownloading(t *testing.T) {
|
|
root := t.TempDir()
|
|
store := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-recover",
|
|
AppID: "app-recover",
|
|
URL: "https://download.invalid/recover",
|
|
Status: downloader.StatusDownloading,
|
|
Attempt: 3,
|
|
Done: 1,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := store.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
if err := os.WriteFile(paths.Part, []byte("ab"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
transport := newBlockingTransport([]byte("abcd"))
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: store,
|
|
Transport: transport,
|
|
Observer: newEventObserver(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
open := waitOpen(t, transport.opens)
|
|
if open.Offset != 2 || open.Validator.ETag != `"v1"` {
|
|
t.Fatalf("recovery open = %#v", open)
|
|
}
|
|
if err := queue.Close(context.Background()); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
loaded, err := store.LoadAll()
|
|
if err != nil {
|
|
t.Fatalf("LoadAll() error = %v", err)
|
|
}
|
|
if len(loaded) != 1 ||
|
|
loaded[0].Status != downloader.StatusQueued ||
|
|
loaded[0].Done != 2 {
|
|
t.Fatalf("recovered metadata = %#v", loaded)
|
|
}
|
|
}
|
|
|
|
func TestQueueRecoveryFinalizesCompletePart(t *testing.T) {
|
|
root := t.TempDir()
|
|
store := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-finalize",
|
|
AppID: "app-finalize",
|
|
URL: "https://download.invalid/finalize",
|
|
Status: downloader.StatusQueued,
|
|
Done: 3,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := store.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
if err := os.WriteFile(paths.Part, []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
transport := &countingTransport{}
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: store,
|
|
Transport: transport,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
recovered, found := queue.Snapshot(task.RequestID)
|
|
if !found || recovered.Status != downloader.StatusCompleted || recovered.Done != 4 {
|
|
t.Fatalf("recovered task = %#v, found=%t", recovered, found)
|
|
}
|
|
if transport.calls != 0 {
|
|
t.Fatalf("transport calls = %d, want 0", transport.calls)
|
|
}
|
|
if _, err := os.Stat(paths.Completed); err != nil {
|
|
t.Fatalf("completed file missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(paths.Part); !os.IsNotExist(err) {
|
|
t.Fatalf("part still exists: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueueRecoveryFailsClosedOnInconsistentFiles(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, paths downloader.TaskPaths)
|
|
}{
|
|
{
|
|
name: "completed metadata missing final",
|
|
setup: func(t *testing.T, _ downloader.TaskPaths) {},
|
|
},
|
|
{
|
|
name: "part and final both exist",
|
|
setup: func(t *testing.T, paths downloader.TaskPaths) {
|
|
if err := os.WriteFile(paths.Part, []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
if err := os.WriteFile(paths.Completed, []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(final) error = %v", err)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
store := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-corrupt",
|
|
AppID: "app-corrupt",
|
|
URL: "https://download.invalid/corrupt",
|
|
Status: downloader.StatusCompleted,
|
|
Done: 4,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := store.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
test.setup(t, paths)
|
|
_, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: store,
|
|
Transport: &countingTransport{},
|
|
})
|
|
if !errors.Is(err, downloader.ErrTaskCorrupt) {
|
|
t.Fatalf("NewQueue() error = %v, want %v", err, downloader.ErrTaskCorrupt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueRecoveryRejectsOversizedUnknownPart(t *testing.T) {
|
|
root := t.TempDir()
|
|
store := storage.NewDownloadTaskStore(root)
|
|
task := downloader.Task{
|
|
SchemaVersion: downloader.TaskSchemaVersion,
|
|
RequestID: "request-unknown-oversized",
|
|
AppID: "app-unknown-oversized",
|
|
URL: "https://download.invalid/unknown-oversized",
|
|
Status: downloader.StatusPaused,
|
|
Done: 5,
|
|
CreatedAt: "2026-07-16T00:00:00Z",
|
|
}
|
|
if err := store.Write(task); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
paths, _ := downloader.DeriveTaskPaths(root, task.RequestID)
|
|
if err := os.WriteFile(paths.Part, []byte("12345"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(part) error = %v", err)
|
|
}
|
|
_, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: store,
|
|
Transport: &countingTransport{},
|
|
MaxUnknown: 4,
|
|
})
|
|
if !errors.Is(err, downloader.ErrTaskCorrupt) {
|
|
t.Fatalf("NewQueue() error = %v, want %v", err, downloader.ErrTaskCorrupt)
|
|
}
|
|
}
|
|
|
|
func TestQueueEnqueueIdentityAndAppConflict(t *testing.T) {
|
|
root := t.TempDir()
|
|
queue, err := downloader.NewQueue(downloader.QueueConfig{
|
|
DownloadsRoot: root,
|
|
Store: storage.NewDownloadTaskStore(root),
|
|
Transport: newBlockingTransport([]byte("data")),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewQueue() error = %v", err)
|
|
}
|
|
defer queue.Close(context.Background())
|
|
request := downloader.EnqueueRequest{
|
|
RequestID: "request-one",
|
|
AppID: "app-one",
|
|
URL: "https://download.invalid/one",
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("Enqueue() error = %v", err)
|
|
}
|
|
if _, err := queue.Enqueue(context.Background(), request); err != nil {
|
|
t.Fatalf("idempotent Enqueue() error = %v", err)
|
|
}
|
|
conflict := request
|
|
conflict.RequestID = "request-two"
|
|
if _, err := queue.Enqueue(context.Background(), conflict); !errors.Is(err, downloader.ErrTaskConflict) {
|
|
t.Fatalf("app conflict error = %v, want %v", err, downloader.ErrTaskConflict)
|
|
}
|
|
}
|
|
|
|
type eventObserver struct {
|
|
events chan downloader.Event
|
|
mu sync.Mutex
|
|
history []downloader.Event
|
|
}
|
|
|
|
type failingEventObserver struct {
|
|
*eventObserver
|
|
failType downloader.EventType
|
|
}
|
|
|
|
func newEventObserver() *eventObserver {
|
|
return &eventObserver{events: make(chan downloader.Event, 128)}
|
|
}
|
|
|
|
func newFailingEventObserver(eventType downloader.EventType) *failingEventObserver {
|
|
return &failingEventObserver{
|
|
eventObserver: newEventObserver(),
|
|
failType: eventType,
|
|
}
|
|
}
|
|
|
|
func (observer *failingEventObserver) PublishDownload(
|
|
ctx context.Context,
|
|
event downloader.Event,
|
|
) error {
|
|
if err := observer.eventObserver.PublishDownload(ctx, event); err != nil {
|
|
return err
|
|
}
|
|
if event.Type == observer.failType {
|
|
return errors.New("injected observer failure")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (observer *eventObserver) PublishDownload(
|
|
ctx context.Context,
|
|
event downloader.Event,
|
|
) error {
|
|
observer.mu.Lock()
|
|
observer.history = append(observer.history, event)
|
|
observer.mu.Unlock()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case observer.events <- event:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (observer *eventObserver) snapshot() []downloader.Event {
|
|
observer.mu.Lock()
|
|
defer observer.mu.Unlock()
|
|
return append([]downloader.Event(nil), observer.history...)
|
|
}
|
|
|
|
func waitEvent(
|
|
t *testing.T,
|
|
events <-chan downloader.Event,
|
|
eventType downloader.EventType,
|
|
requestID string,
|
|
) downloader.Event {
|
|
t.Helper()
|
|
timer := time.NewTimer(3 * time.Second)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case event := <-events:
|
|
if event.Type == eventType && event.RequestID == requestID {
|
|
return event
|
|
}
|
|
case <-timer.C:
|
|
t.Fatalf("timed out waiting for %s/%s", eventType, requestID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertNoEvent(
|
|
t *testing.T,
|
|
events <-chan downloader.Event,
|
|
eventType downloader.EventType,
|
|
requestID string,
|
|
) {
|
|
t.Helper()
|
|
timer := time.NewTimer(100 * time.Millisecond)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case event := <-events:
|
|
if event.Type == eventType && event.RequestID == requestID {
|
|
t.Fatalf("unexpected event %#v", event)
|
|
}
|
|
case <-timer.C:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
type blockingTransport struct {
|
|
data []byte
|
|
|
|
mu sync.Mutex
|
|
releases map[string]chan struct{}
|
|
active int
|
|
maxActive int
|
|
opens chan downloader.OpenRequest
|
|
}
|
|
|
|
func newBlockingTransport(data []byte) *blockingTransport {
|
|
return &blockingTransport{
|
|
data: append([]byte(nil), data...),
|
|
releases: make(map[string]chan struct{}),
|
|
opens: make(chan downloader.OpenRequest, 32),
|
|
}
|
|
}
|
|
|
|
func (transport *blockingTransport) Open(
|
|
ctx context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
release := make(chan struct{})
|
|
transport.releases[request.URL] = release
|
|
transport.active++
|
|
if transport.active > transport.maxActive {
|
|
transport.maxActive = transport.active
|
|
}
|
|
transport.mu.Unlock()
|
|
transport.opens <- request
|
|
|
|
offset := request.Offset
|
|
if offset > int64(len(transport.data)) {
|
|
return downloader.OpenResponse{}, downloader.ErrRangeMismatch
|
|
}
|
|
body := &blockingBody{
|
|
ctx: ctx,
|
|
release: release,
|
|
data: append([]byte(nil), transport.data[offset:]...),
|
|
onClose: func() {
|
|
transport.mu.Lock()
|
|
transport.active--
|
|
transport.mu.Unlock()
|
|
},
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: body,
|
|
TotalKnown: true,
|
|
Total: int64(len(transport.data)),
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: int64(len(transport.data)) - offset,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
func (transport *blockingTransport) release(url string) {
|
|
transport.mu.Lock()
|
|
release := transport.releases[url]
|
|
transport.mu.Unlock()
|
|
if release != nil {
|
|
close(release)
|
|
}
|
|
}
|
|
|
|
func (transport *blockingTransport) maxActiveValue() int {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
return transport.maxActive
|
|
}
|
|
|
|
type blockingBody struct {
|
|
ctx context.Context
|
|
release <-chan struct{}
|
|
data []byte
|
|
sent bool
|
|
onClose func()
|
|
once sync.Once
|
|
}
|
|
|
|
func (body *blockingBody) Read(buffer []byte) (int, error) {
|
|
if body.sent {
|
|
return 0, io.EOF
|
|
}
|
|
select {
|
|
case <-body.ctx.Done():
|
|
return 0, body.ctx.Err()
|
|
case <-body.release:
|
|
}
|
|
body.sent = true
|
|
return copy(buffer, body.data), nil
|
|
}
|
|
|
|
func (body *blockingBody) Close() error {
|
|
body.once.Do(body.onClose)
|
|
return nil
|
|
}
|
|
|
|
type resumeTransport struct {
|
|
mu sync.Mutex
|
|
opens []downloader.OpenRequest
|
|
}
|
|
|
|
func (transport *resumeTransport) Open(
|
|
ctx context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
transport.opens = append(transport.opens, request)
|
|
transport.mu.Unlock()
|
|
switch request.Offset {
|
|
case 0:
|
|
return downloader.OpenResponse{
|
|
Body: &prefixThenBlockBody{ctx: ctx, prefix: []byte("ab")},
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
case 2:
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(bytes.NewReader([]byte("cd"))),
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 2,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
default:
|
|
return downloader.OpenResponse{}, downloader.ErrRangeMismatch
|
|
}
|
|
}
|
|
|
|
func (transport *resumeTransport) openRequests() []downloader.OpenRequest {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
return append([]downloader.OpenRequest(nil), transport.opens...)
|
|
}
|
|
|
|
type prefixThenBlockBody struct {
|
|
ctx context.Context
|
|
prefix []byte
|
|
sent bool
|
|
}
|
|
|
|
func (body *prefixThenBlockBody) Read(buffer []byte) (int, error) {
|
|
if !body.sent {
|
|
body.sent = true
|
|
return copy(buffer, body.prefix), nil
|
|
}
|
|
<-body.ctx.Done()
|
|
return 0, body.ctx.Err()
|
|
}
|
|
|
|
func (body *prefixThenBlockBody) Close() error {
|
|
return nil
|
|
}
|
|
|
|
var errInjectedBodyClose = errors.New("injected response body close failure")
|
|
|
|
type closeErrorTransport struct {
|
|
opened chan struct{}
|
|
}
|
|
|
|
func (transport *closeErrorTransport) Open(
|
|
ctx context.Context,
|
|
_ downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
return downloader.OpenResponse{
|
|
Body: &closeErrorBody{ctx: ctx, opened: transport.opened},
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
type closeErrorBody struct {
|
|
ctx context.Context
|
|
opened chan<- struct{}
|
|
}
|
|
|
|
func (body *closeErrorBody) Read([]byte) (int, error) {
|
|
body.opened <- struct{}{}
|
|
<-body.ctx.Done()
|
|
return 0, body.ctx.Err()
|
|
}
|
|
|
|
func (*closeErrorBody) Close() error {
|
|
return errInjectedBodyClose
|
|
}
|
|
|
|
type fullThenBlockTransport struct {
|
|
data []byte
|
|
|
|
mu sync.Mutex
|
|
calls int
|
|
written chan struct{}
|
|
}
|
|
|
|
func newFullThenBlockTransport(data []byte) *fullThenBlockTransport {
|
|
return &fullThenBlockTransport{
|
|
data: append([]byte(nil), data...),
|
|
written: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (transport *fullThenBlockTransport) Open(
|
|
ctx context.Context,
|
|
_ downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
transport.calls++
|
|
call := transport.calls
|
|
transport.mu.Unlock()
|
|
if call != 1 {
|
|
return downloader.OpenResponse{}, errors.New("unexpected second transport open")
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: &fullThenBlockBody{
|
|
ctx: ctx,
|
|
data: transport.data,
|
|
written: transport.written,
|
|
},
|
|
TotalKnown: true,
|
|
Total: int64(len(transport.data)),
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: int64(len(transport.data)),
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
func (transport *fullThenBlockTransport) waitWritten(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case <-transport.written:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for complete part write")
|
|
}
|
|
}
|
|
|
|
func (transport *fullThenBlockTransport) callsValue() int {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
return transport.calls
|
|
}
|
|
|
|
type fullThenBlockBody struct {
|
|
ctx context.Context
|
|
data []byte
|
|
written chan<- struct{}
|
|
sent bool
|
|
}
|
|
|
|
func (body *fullThenBlockBody) Read(buffer []byte) (int, error) {
|
|
if !body.sent {
|
|
body.sent = true
|
|
body.written <- struct{}{}
|
|
return copy(buffer, body.data), nil
|
|
}
|
|
<-body.ctx.Done()
|
|
return 0, body.ctx.Err()
|
|
}
|
|
|
|
func (*fullThenBlockBody) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type fullThenErrorTransport struct {
|
|
data []byte
|
|
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (transport *fullThenErrorTransport) Open(
|
|
_ context.Context,
|
|
_ downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
transport.calls++
|
|
call := transport.calls
|
|
transport.mu.Unlock()
|
|
if call != 1 {
|
|
return downloader.OpenResponse{}, errors.New("unexpected second transport open")
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(&errorAfterReader{data: transport.data}),
|
|
TotalKnown: true,
|
|
Total: int64(len(transport.data)),
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: int64(len(transport.data)),
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
func (transport *fullThenErrorTransport) callsValue() int {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
return transport.calls
|
|
}
|
|
|
|
type flakyTransport struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
type interruptedTransport struct {
|
|
mu sync.Mutex
|
|
opens []downloader.OpenRequest
|
|
}
|
|
|
|
func (transport *interruptedTransport) Open(
|
|
_ context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
transport.opens = append(transport.opens, request)
|
|
call := len(transport.opens)
|
|
transport.mu.Unlock()
|
|
if call == 1 {
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(&errorAfterReader{data: []byte("ab")}),
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(bytes.NewReader([]byte("cd"))),
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 2,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
func (transport *interruptedTransport) openRequests() []downloader.OpenRequest {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
return append([]downloader.OpenRequest(nil), transport.opens...)
|
|
}
|
|
|
|
type errorAfterReader struct {
|
|
data []byte
|
|
sent bool
|
|
}
|
|
|
|
func (reader *errorAfterReader) Read(buffer []byte) (int, error) {
|
|
if !reader.sent {
|
|
reader.sent = true
|
|
return copy(buffer, reader.data), nil
|
|
}
|
|
return 0, errors.New("connection interrupted")
|
|
}
|
|
|
|
type restartTransport struct{}
|
|
|
|
func (*restartTransport) Open(
|
|
_ context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
if request.Offset != 2 || request.Validator.ETag != `"v1"` {
|
|
return downloader.OpenResponse{}, downloader.ErrRangeMismatch
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(bytes.NewReader([]byte("WXYZ"))),
|
|
Restart: true,
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v2"`},
|
|
}, nil
|
|
}
|
|
|
|
type restartWriteFailStore struct {
|
|
inner *storage.DownloadTaskStore
|
|
mu sync.Mutex
|
|
failed bool
|
|
}
|
|
|
|
func (store *restartWriteFailStore) Write(task downloader.Task) error {
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
if !store.failed &&
|
|
task.Status == downloader.StatusDownloading &&
|
|
task.Attempt == 3 {
|
|
store.failed = true
|
|
return errors.New("injected restart metadata write failure")
|
|
}
|
|
return store.inner.Write(task)
|
|
}
|
|
|
|
func (store *restartWriteFailStore) LoadAll() ([]downloader.Task, error) {
|
|
return store.inner.LoadAll()
|
|
}
|
|
|
|
func (store *restartWriteFailStore) Delete(requestID string) error {
|
|
return store.inner.Delete(requestID)
|
|
}
|
|
|
|
type recordingFullTransport struct {
|
|
data []byte
|
|
|
|
mu sync.Mutex
|
|
opens []downloader.OpenRequest
|
|
}
|
|
|
|
func (transport *recordingFullTransport) Open(
|
|
_ context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
transport.opens = append(transport.opens, request)
|
|
transport.mu.Unlock()
|
|
if request.Offset < 0 || request.Offset > int64(len(transport.data)) {
|
|
return downloader.OpenResponse{}, downloader.ErrRangeMismatch
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(bytes.NewReader(transport.data[request.Offset:])),
|
|
TotalKnown: true,
|
|
Total: int64(len(transport.data)),
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: int64(len(transport.data)) - request.Offset,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
func (transport *recordingFullTransport) openRequests() []downloader.OpenRequest {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
return append([]downloader.OpenRequest(nil), transport.opens...)
|
|
}
|
|
|
|
type chunkedUnknownTransport struct {
|
|
data []byte
|
|
}
|
|
|
|
func (transport *chunkedUnknownTransport) Open(
|
|
_ context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
if request.Offset != 0 {
|
|
return downloader.OpenResponse{}, downloader.ErrRangeMismatch
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(&oneByteReader{data: transport.data}),
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: int64(len(transport.data)),
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
type unknownResumeTransport struct{}
|
|
|
|
func (*unknownResumeTransport) Open(
|
|
_ context.Context,
|
|
request downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
if request.Offset != 2 || request.Validator.ETag != `"v1"` {
|
|
return downloader.OpenResponse{}, downloader.ErrRangeMismatch
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(bytes.NewReader([]byte("cd"))),
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 2,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
type oneByteReader struct {
|
|
data []byte
|
|
offset int
|
|
}
|
|
|
|
func (reader *oneByteReader) Read(buffer []byte) (int, error) {
|
|
if reader.offset >= len(reader.data) {
|
|
return 0, io.EOF
|
|
}
|
|
buffer[0] = reader.data[reader.offset]
|
|
reader.offset++
|
|
return 1, nil
|
|
}
|
|
|
|
type countingTransport struct {
|
|
calls int
|
|
}
|
|
|
|
func (transport *countingTransport) Open(
|
|
context.Context,
|
|
downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.calls++
|
|
return downloader.OpenResponse{}, errors.New("unexpected transport call")
|
|
}
|
|
|
|
type advancingClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
}
|
|
|
|
func newAdvancingClock() *advancingClock {
|
|
return &advancingClock{
|
|
now: time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func (clock *advancingClock) Now() time.Time {
|
|
clock.mu.Lock()
|
|
defer clock.mu.Unlock()
|
|
current := clock.now
|
|
clock.now = clock.now.Add(time.Second)
|
|
return current
|
|
}
|
|
|
|
func (transport *flakyTransport) Open(
|
|
context.Context,
|
|
downloader.OpenRequest,
|
|
) (downloader.OpenResponse, error) {
|
|
transport.mu.Lock()
|
|
defer transport.mu.Unlock()
|
|
transport.calls++
|
|
if transport.calls == 1 {
|
|
return downloader.OpenResponse{}, errors.New("temporary network failure")
|
|
}
|
|
return downloader.OpenResponse{
|
|
Body: io.NopCloser(bytes.NewReader([]byte("data"))),
|
|
TotalKnown: true,
|
|
Total: 4,
|
|
ResponseLengthKnown: true,
|
|
ResponseLength: 4,
|
|
Validator: downloader.EntityValidator{ETag: `"v1"`},
|
|
}, nil
|
|
}
|
|
|
|
func waitOpen(
|
|
t *testing.T,
|
|
opens <-chan downloader.OpenRequest,
|
|
) downloader.OpenRequest {
|
|
t.Helper()
|
|
select {
|
|
case request := <-opens:
|
|
return request
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for transfer start")
|
|
return downloader.OpenRequest{}
|
|
}
|
|
}
|