A truncated debrid download (in-memory byte counter hit 100% while the
NFS write-back silently dropped most of the bytes) was marked completed.
The 1.1.6 fsync fix closed the debrid-specific hole; this generalizes the
guarantee so "completed" never means a corrupt file on ANY backend.
- IntegrityError + bounded retry: on a corrupt/short result the manager
re-downloads the same source up to 3x (clean start), then surfaces the
task as damaged ("corrupt download:" prefix) instead of completing it.
- verify (size mismatch / empty), debrid (incomplete / post-write / flush),
torrent (BytesMissing), usenet (par2 unrepairable / repair-failed) all
classify integrity failures so they route through the retry/damaged path.
- scanner: a file ffprobe can't read is emitted as a damaged library_item
(reason "unreadable") instead of being silently dropped from the sync.
- tests: manager retry-then-success + retry-exhausted-then-damaged,
verifying->resolving transition, damaged sync item.
128 lines
4.5 KiB
Go
128 lines
4.5 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/torrentclaw/unarr/internal/agent"
|
|
)
|
|
|
|
// truncatingMockDownloader writes a SHORT file (failing the on-disk verify) until
|
|
// goodOnAttempt, then writes a full file. reportedSize is what each Result claims,
|
|
// so verify() compares the advertised size against the (initially truncated) bytes
|
|
// on disk — the exact shape of the 2026-06-15 debrid NFS truncation.
|
|
type truncatingMockDownloader struct {
|
|
dir string
|
|
reportedSize int64
|
|
goodOnAttempt int // 1-based attempt that finally writes a full file; 0 = never
|
|
callCount atomic.Int32
|
|
}
|
|
|
|
func (m *truncatingMockDownloader) Method() DownloadMethod { return MethodTorrent }
|
|
func (m *truncatingMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
|
|
return true, nil
|
|
}
|
|
func (m *truncatingMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
|
|
n := int(m.callCount.Add(1))
|
|
path := filepath.Join(m.dir, "movie.mkv")
|
|
size := int64(10) // truncated stub
|
|
if m.goodOnAttempt > 0 && n >= m.goodOnAttempt {
|
|
size = m.reportedSize
|
|
}
|
|
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Result{FilePath: path, FileName: "movie.mkv", Method: MethodTorrent, Size: m.reportedSize}, nil
|
|
}
|
|
func (m *truncatingMockDownloader) Pause(_ string) error { return nil }
|
|
func (m *truncatingMockDownloader) Cancel(_ string) error { return nil }
|
|
func (m *truncatingMockDownloader) Shutdown(_ context.Context) error { return nil }
|
|
|
|
// captureReporter builds a ProgressReporter over a mockStatusReporter we keep a
|
|
// handle to, so the test can read the final reported StatusUpdate.
|
|
func captureReporter() (*ProgressReporter, *mockStatusReporter) {
|
|
reporter := &mockStatusReporter{}
|
|
return &ProgressReporter{
|
|
reporter: reporter,
|
|
interval: 50 * time.Millisecond,
|
|
latest: make(map[string]*Task),
|
|
lastReported: make(map[string]TaskStatus),
|
|
}, reporter
|
|
}
|
|
|
|
func terminalUpdate(t *testing.T, r *mockStatusReporter, taskID string) agent.StatusUpdate {
|
|
t.Helper()
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i := len(r.calls) - 1; i >= 0; i-- {
|
|
c := r.calls[i]
|
|
if c.TaskID == taskID && (c.Status == "completed" || c.Status == "failed") {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("no terminal (completed/failed) status update for %s", taskID)
|
|
return agent.StatusUpdate{}
|
|
}
|
|
|
|
// A truncated download is re-tried clean and, once it lands intact, completes —
|
|
// "completed" is never reported for the corrupt attempt.
|
|
func TestManagerPipeline_IntegrityRetry_ThenSucceeds(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pr, reporter := captureReporter()
|
|
dl := &truncatingMockDownloader{dir: dir, reportedSize: 10000, goodOnAttempt: 2}
|
|
|
|
mgr := NewManager(ManagerConfig{MaxConcurrent: 1, OutputDir: dir}, pr, dl)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
go pr.Run(ctx)
|
|
|
|
const taskID = "integrity-retry-ok-123456"
|
|
mgr.Submit(ctx, agent.Task{
|
|
ID: taskID, InfoHash: "abc123def456abc123def456abc123def456abc1",
|
|
Title: "Retry Test", PreferredMethod: "torrent",
|
|
})
|
|
mgr.Wait()
|
|
|
|
if got := dl.callCount.Load(); got != 2 {
|
|
t.Errorf("download attempts = %d, want 2 (1 truncated + 1 clean)", got)
|
|
}
|
|
if u := terminalUpdate(t, reporter, taskID); u.Status != "completed" {
|
|
t.Errorf("final status = %q (%s), want completed", u.Status, u.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
// A persistently-truncated download exhausts the bounded retries and is surfaced
|
|
// as damaged (failed + the stable corrupt-download marker), never completed.
|
|
func TestManagerPipeline_IntegrityRetry_ExhaustsThenDamaged(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pr, reporter := captureReporter()
|
|
dl := &truncatingMockDownloader{dir: dir, reportedSize: 10000, goodOnAttempt: 0}
|
|
|
|
mgr := NewManager(ManagerConfig{MaxConcurrent: 1, OutputDir: dir}, pr, dl)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
go pr.Run(ctx)
|
|
|
|
const taskID = "integrity-retry-bad-123456"
|
|
mgr.Submit(ctx, agent.Task{
|
|
ID: taskID, InfoHash: "abc123def456abc123def456abc123def456abc1",
|
|
Title: "Damaged Test", PreferredMethod: "torrent",
|
|
})
|
|
mgr.Wait()
|
|
|
|
if got := dl.callCount.Load(); got != 3 {
|
|
t.Errorf("download attempts = %d, want 3 (bounded retries)", got)
|
|
}
|
|
u := terminalUpdate(t, reporter, taskID)
|
|
if u.Status != "failed" {
|
|
t.Fatalf("final status = %q, want failed", u.Status)
|
|
}
|
|
if !strings.HasPrefix(u.ErrorMessage, damagedErrorPrefix) {
|
|
t.Errorf("error message = %q, want prefix %q", u.ErrorMessage, damagedErrorPrefix)
|
|
}
|
|
}
|