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:
parent
2be92516c6
commit
1cad73b9a7
9 changed files with 196 additions and 4 deletions
63
internal/engine/diskspace.go
Normal file
63
internal/engine/diskspace.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/torrentclaw/unarr/internal/agent"
|
||||
)
|
||||
|
||||
// InsufficientDiskError is returned by CheckDiskSpace when a download's expected
|
||||
// size (plus a reserve that keeps the filesystem healthy) won't fit in the free
|
||||
// space of its target directory. The manager treats it as terminal — it does NOT
|
||||
// fall back to another source (a different source would fill the same disk) and
|
||||
// surfaces the message to the web as the task's error.
|
||||
type InsufficientDiskError struct {
|
||||
Dir string
|
||||
Need int64 // bytes the download still needs to write
|
||||
Free int64 // bytes currently free on Dir's filesystem
|
||||
Reserve int64 // bytes to keep free after the download
|
||||
}
|
||||
|
||||
func (e *InsufficientDiskError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"insufficient disk space in %s: need %s + %s reserve, only %s free",
|
||||
e.Dir, formatBytes(e.Need), formatBytes(e.Reserve), formatBytes(e.Free),
|
||||
)
|
||||
}
|
||||
|
||||
// IsInsufficientDisk reports whether err is (or wraps) an InsufficientDiskError.
|
||||
func IsInsufficientDisk(err error) bool {
|
||||
var d *InsufficientDiskError
|
||||
return errors.As(err, &d)
|
||||
}
|
||||
|
||||
// CheckDiskSpace fails fast when dir's filesystem can't hold needBytes while
|
||||
// keeping reserveBytes free. It's the pre-flight guard so a download never fills
|
||||
// the disk to 0 mid-write (which corrupts the partial file and can wedge the OS).
|
||||
//
|
||||
// Best-effort by design: a non-positive needBytes (size unknown) or a failure to
|
||||
// stat the filesystem returns nil rather than block a download on a guard we
|
||||
// can't evaluate — the OS-level ENOSPC stays the backstop.
|
||||
func CheckDiskSpace(dir string, needBytes, reserveBytes int64) error {
|
||||
if needBytes <= 0 {
|
||||
return nil // size unknown — nothing to check against
|
||||
}
|
||||
free, _, err := agent.DiskInfo(dir)
|
||||
if err != nil {
|
||||
log.Printf("[disk] free-space pre-flight skipped for %q: stat error: %v", dir, err)
|
||||
return nil
|
||||
}
|
||||
if free <= 0 {
|
||||
// Distinct from a stat error: DiskInfo succeeded but reports no free
|
||||
// space. Don't block on a value we can't trust (0/negative) — log it so a
|
||||
// genuinely-full disk is visible rather than masked as a generic skip.
|
||||
log.Printf("[disk] free-space pre-flight skipped for %q: DiskInfo reported non-positive free (%d)", dir, free)
|
||||
return nil
|
||||
}
|
||||
if free-needBytes < reserveBytes {
|
||||
return &InsufficientDiskError{Dir: dir, Need: needBytes, Free: free, Reserve: reserveBytes}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue