feat(downloads): pre-flight free-disk guard before each download (hueco medio)

CheckDiskSpace (internal/engine/diskspace.go) refuses a download before
writing when its expected size wouldn't leave a configurable reserve free,
so a download never fills the filesystem to 0 mid-write (which corrupts the
partial file). Wired into all three downloaders ahead of any write — torrent
(DataDir), debrid (outputDir, resume-aware), usenet (outputDir, fresh only).
Reserve from downloads.min_free_disk_mb (default 2048 MiB) via SetMinFreeBytes.

The manager treats an InsufficientDiskError as terminal — no source fallback,
since another source would fill the same disk — and surfaces the clear message.
Best-effort: unknown size or a stat failure doesn't block (ENOSPC stays the
backstop). Also hardens formatBytes against an exabyte-scale out-of-bounds panic.
This commit is contained in:
Deivid Soto 2026-05-31 21:48:34 +02:00
parent 2be92516c6
commit 1cad73b9a7
9 changed files with 196 additions and 4 deletions

View file

@ -0,0 +1,56 @@
package engine
import (
"path/filepath"
"strings"
"testing"
)
func TestCheckDiskSpace_Enough(t *testing.T) {
// A tiny need in a real temp dir (huge free space) → nil.
if err := CheckDiskSpace(t.TempDir(), 1024, 0); err != nil {
t.Errorf("expected nil for a 1 KiB need, got %v", err)
}
}
func TestCheckDiskSpace_Insufficient(t *testing.T) {
// Need more than any real disk has → InsufficientDiskError.
err := CheckDiskSpace(t.TempDir(), 1<<62, 0)
if err == nil {
t.Fatal("expected an error for an impossibly large need")
}
if !IsInsufficientDisk(err) {
t.Errorf("IsInsufficientDisk = false, want true (err=%v)", err)
}
if !strings.Contains(err.Error(), "insufficient disk space") {
t.Errorf("error message = %q, want it to mention insufficient disk space", err.Error())
}
}
func TestCheckDiskSpace_ReserveTriggers(t *testing.T) {
// Tiny need but an impossibly large reserve → free-need < reserve → error.
err := CheckDiskSpace(t.TempDir(), 1024, 1<<62)
if !IsInsufficientDisk(err) {
t.Errorf("expected insufficient when reserve exceeds free space, got %v", err)
}
}
func TestCheckDiskSpace_UnknownSize(t *testing.T) {
// need <= 0 means the size is unknown — the check must be skipped, even with
// an enormous reserve.
if err := CheckDiskSpace(t.TempDir(), 0, 1<<62); err != nil {
t.Errorf("need=0 must skip the check, got %v", err)
}
if err := CheckDiskSpace(t.TempDir(), -5, 1<<62); err != nil {
t.Errorf("negative need must skip the check, got %v", err)
}
}
func TestCheckDiskSpace_BadDirIsBestEffort(t *testing.T) {
// An unstat-able path → DiskInfo errors → best-effort nil (never block a
// download on a guard we can't evaluate; ENOSPC stays the backstop).
bad := filepath.Join(t.TempDir(), "does", "not", "exist")
if err := CheckDiskSpace(bad, 1<<40, 0); err != nil {
t.Errorf("unstat-able dir must skip the check, got %v", err)
}
}