package downloader import ( "io/fs" "os" "path/filepath" "strings" "time" "github.com/mudler/xlog" ) // PartialFileSuffix marks an in-progress download. The success path renames the // partial to its final name, so any leftover with this suffix is an unfinished // transfer. const PartialFileSuffix = ".partial" // CleanupStalePartialFiles removes *.partial files under root whose last // modification is older than olderThan, returning the number removed. These are // abandoned downloads left by a process killed mid-transfer (OOM, restart) or // by a stall whose cleanup never ran; without reaping they accumulate and can // fill the models volume. A still-in-progress download touches its .partial on // every write, so a generous olderThan never trims an active transfer. // // A missing root is not an error (nothing to clean). Unreadable entries are // skipped so one bad file does not abort the whole sweep. func CleanupStalePartialFiles(root string, olderThan time.Duration) (int, error) { if _, err := os.Stat(root); err != nil { if os.IsNotExist(err) { return 0, nil } return 0, err } cutoff := time.Now().Add(-olderThan) // Collect candidates during the walk and delete them afterwards rather than // mutating the tree from inside the WalkDir callback (avoids the symlink // TOCTOU class flagged by gosec G122, and never removes an entry mid-walk). var stale []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return nil // skip unreadable subtree, keep going } if d.IsDir() || !strings.HasSuffix(d.Name(), PartialFileSuffix) { return nil } info, err := d.Info() if err != nil || info.ModTime().After(cutoff) { return nil } stale = append(stale, path) return nil }) if err != nil { return 0, err } removed := 0 for _, path := range stale { if err := os.Remove(path); err != nil { xlog.Warn("failed to remove stale partial download", "file", path, "error", err) continue } removed++ xlog.Info("removed stale partial download", "file", path) } return removed, nil }