Both are named for what they do rather than the tool underneath, the way Services is. Old links and saved rail orders still land in the right place. The Go packages and their endpoints keep their names, with a pointer in each to what the UI calls them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
517 lines
15 KiB
Go
517 lines
15 KiB
Go
// Package space measures what is using a disk — it is what the UI calls the **Usage** tab
|
|
// (renamed from "Space" on 2026-09-24; the package and its /api/space endpoints kept the
|
|
// old name rather than churn a working API): a parallel walk of the filesystems a disk
|
|
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
|
|
//
|
|
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
|
// mount — but every mountpoint the disk backs is a root of the same scan, and the roots
|
|
// are grafted into one tree at their paths, so a ZFS pool of many datasets, or a disk with
|
|
// / and /home on separate partitions, reads as one picture.
|
|
//
|
|
// Files below KeepMin are not kept one by one: a directory of a million small files is
|
|
// one "smaller files" entry with their count and total, which is all a treemap can show
|
|
// of them anyway, and what keeps a scan of a few million files within a few hundred MB.
|
|
//
|
|
// A walk can start from the last one. Given the previous tree, a directory is read again
|
|
// only when something says it changed: on ZFS, `zfs diff` against a snapshot older than
|
|
// the last scan names every path that changed, and directories it does not name are kept
|
|
// as they were without so much as a stat; elsewhere a directory whose mtime is what it was
|
|
// keeps its list and only its kept files are stat'ed again, since a file can grow without
|
|
// its directory noticing. A directory the last walk did not finish (it was cancelled) is
|
|
// read again whatever else is true, which is what makes a cancelled scan resumable.
|
|
package space
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// KeepMin is the smallest file kept as its own node.
|
|
const KeepMin = 128 << 10
|
|
|
|
// Node is a directory or a file. Sizes are bytes allocated on disk (what fills the disk);
|
|
// Apparent is the length. For a directory both include everything beneath it.
|
|
type Node struct {
|
|
Name string `json:"name"`
|
|
Dir bool `json:"dir"`
|
|
Size int64 `json:"size"`
|
|
Apparent int64 `json:"apparent"`
|
|
MTime int64 `json:"mtime"` // unix seconds
|
|
MTimeNs int64 `json:"-"` // directories: the same to the nanosecond, so a change in the same second is not missed
|
|
Files uint32 `json:"files"` // beneath a directory (kept and small alike)
|
|
Dirs uint32 `json:"dirs"`
|
|
SmallN uint32 `json:"small_n,omitempty"` // files here below KeepMin, not listed
|
|
SmallSize int64 `json:"small_size,omitempty"` // their allocated bytes
|
|
Mount bool `json:"mount,omitempty"` // another filesystem starts here (not this disk's, or a root of its own)
|
|
Denied bool `json:"denied,omitempty"` // could not be read
|
|
Partial bool `json:"partial,omitempty"` // the walk was cancelled before this directory was read
|
|
Children []*Node `json:"children,omitempty"` // directories: sorted by Size, largest first
|
|
}
|
|
|
|
// Progress is how far a running walk has got.
|
|
type Progress struct {
|
|
Files int64 `json:"files"`
|
|
Dirs int64 `json:"dirs"` // directories read
|
|
Reused int64 `json:"reused"` // directories kept from the last scan without reading
|
|
Bytes int64 `json:"bytes"`
|
|
Denied int64 `json:"denied"`
|
|
Current string `json:"current"`
|
|
}
|
|
|
|
// Mode is how a root is walked when there is a previous tree.
|
|
type Mode int
|
|
|
|
const (
|
|
ModeFull Mode = iota // read everything
|
|
ModeMTime // read a directory again only if its mtime changed; stat its kept files
|
|
ModeDiff // read only the directories a change list names
|
|
)
|
|
|
|
// Options is what a walk starts from.
|
|
type Options struct {
|
|
Prev *Node // the last scan's tree, or nil
|
|
// Modes says how each root is walked (by root path); a root not listed is ModeFull.
|
|
Modes map[string]Mode
|
|
// Dirty is every directory a change list named (ModeDiff roots): those are read again.
|
|
// Touched is every ancestor of a dirty directory: those are kept but looked into.
|
|
Dirty, Touched map[string]bool
|
|
}
|
|
|
|
type walker struct {
|
|
ctx context.Context
|
|
opts Options
|
|
mu sync.Mutex
|
|
queue []job
|
|
cond *sync.Cond
|
|
pending int // dirs queued or being read
|
|
files atomic.Int64
|
|
dirs atomic.Int64
|
|
reused atomic.Int64
|
|
bytes atomic.Int64
|
|
denied atomic.Int64
|
|
partial atomic.Int64
|
|
curMu sync.Mutex
|
|
current string
|
|
stopping bool
|
|
}
|
|
|
|
type job struct {
|
|
path string
|
|
node *Node
|
|
prev *Node // the same directory in the last scan, if it had one
|
|
dev uint64 // the filesystem this root is on; a child on another is a mountpoint, not descended
|
|
mode Mode
|
|
}
|
|
|
|
// Result is what a walk returns besides the tree.
|
|
type Result struct {
|
|
Root *Node
|
|
Partial int64 // directories not read because the walk was cancelled
|
|
Reused int64
|
|
Read int64
|
|
Complete bool
|
|
}
|
|
|
|
// Walk measures roots (mountpoints, absolute) and returns them grafted into one tree whose
|
|
// root is "/". progress, if set, is called about four times a second. A cancelled walk
|
|
// still returns the tree, with the directories it did not get to marked Partial.
|
|
func Walk(ctx context.Context, roots []string, opts Options, progress func(Progress)) (Result, error) {
|
|
w := &walker{ctx: ctx, opts: opts}
|
|
w.cond = sync.NewCond(&w.mu)
|
|
root := &Node{Name: "/", Dir: true}
|
|
sort.Strings(roots)
|
|
trees := map[string]*Node{}
|
|
for _, r := range roots {
|
|
r = filepath.Clean(r)
|
|
if !filepath.IsAbs(r) {
|
|
continue
|
|
}
|
|
var st syscall.Stat_t
|
|
if err := syscall.Lstat(r, &st); err != nil || st.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
|
continue
|
|
}
|
|
n := &Node{Name: filepath.Base(r), Dir: true, MTime: int64(st.Mtim.Sec), MTimeNs: st.Mtim.Nano()}
|
|
if r == "/" {
|
|
n = root
|
|
}
|
|
trees[r] = n
|
|
var prev *Node
|
|
if opts.Prev != nil {
|
|
prev = Find(opts.Prev, r)
|
|
}
|
|
mode := opts.Modes[r]
|
|
if prev == nil {
|
|
mode = ModeFull
|
|
}
|
|
w.push(job{path: r, node: n, prev: prev, dev: uint64(st.Dev), mode: mode})
|
|
}
|
|
if len(trees) == 0 {
|
|
return Result{Root: root}, os.ErrNotExist
|
|
}
|
|
workers := 32
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); w.run() }()
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { wg.Wait(); close(done) }()
|
|
tick := time.NewTicker(250 * time.Millisecond)
|
|
defer tick.Stop()
|
|
cancelled := false
|
|
for waiting := true; waiting; {
|
|
select {
|
|
case <-done:
|
|
waiting = false
|
|
case <-tick.C:
|
|
if progress != nil {
|
|
progress(w.snapshot())
|
|
}
|
|
case <-ctx.Done():
|
|
cancelled = true
|
|
w.mu.Lock()
|
|
w.stopping = true
|
|
for _, j := range w.queue { // never read: say so, so the next walk reads them
|
|
j.node.Partial = true
|
|
w.partial.Add(1)
|
|
}
|
|
w.queue = nil
|
|
w.cond.Broadcast()
|
|
w.mu.Unlock()
|
|
<-done
|
|
waiting = false
|
|
}
|
|
}
|
|
if progress != nil {
|
|
progress(w.snapshot())
|
|
}
|
|
// graft each root at its path; deepest last so parents exist first
|
|
for _, r := range roots {
|
|
n, ok := trees[filepath.Clean(r)]
|
|
if !ok || n == root {
|
|
continue
|
|
}
|
|
graft(root, filepath.Clean(r), n)
|
|
}
|
|
total(root)
|
|
res := Result{Root: root, Partial: w.partial.Load(), Reused: w.reused.Load(), Read: w.dirs.Load(), Complete: !cancelled}
|
|
if cancelled {
|
|
return res, ctx.Err()
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (w *walker) snapshot() Progress {
|
|
w.curMu.Lock()
|
|
cur := w.current
|
|
w.curMu.Unlock()
|
|
return Progress{Files: w.files.Load(), Dirs: w.dirs.Load(), Reused: w.reused.Load(), Bytes: w.bytes.Load(), Denied: w.denied.Load(), Current: cur}
|
|
}
|
|
|
|
func (w *walker) push(j job) {
|
|
w.mu.Lock()
|
|
if w.stopping {
|
|
j.node.Partial = true
|
|
w.partial.Add(1)
|
|
w.mu.Unlock()
|
|
return
|
|
}
|
|
w.queue = append(w.queue, j)
|
|
w.pending++
|
|
w.cond.Signal()
|
|
w.mu.Unlock()
|
|
}
|
|
|
|
// run takes directories off the queue until there are none left and none being read.
|
|
func (w *walker) run() {
|
|
for {
|
|
w.mu.Lock()
|
|
for len(w.queue) == 0 && w.pending > 0 && !w.stopping {
|
|
w.cond.Wait()
|
|
}
|
|
if w.stopping || (len(w.queue) == 0 && w.pending == 0) {
|
|
w.cond.Broadcast()
|
|
w.mu.Unlock()
|
|
return
|
|
}
|
|
j := w.queue[len(w.queue)-1] // depth first: keeps the queue short and the disk head local
|
|
w.queue = w.queue[:len(w.queue)-1]
|
|
w.mu.Unlock()
|
|
w.visit(j)
|
|
w.mu.Lock()
|
|
w.pending--
|
|
if w.pending == 0 {
|
|
w.cond.Broadcast()
|
|
}
|
|
w.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func join(dir, name string) string {
|
|
if dir == "/" {
|
|
return "/" + name
|
|
}
|
|
return dir + "/" + name
|
|
}
|
|
|
|
// visit decides whether a directory must be read or can be taken from the last scan.
|
|
func (w *walker) visit(j job) {
|
|
if j.prev == nil || j.prev.Partial || j.prev.Denied || j.mode == ModeFull {
|
|
w.readDir(j)
|
|
return
|
|
}
|
|
switch j.mode {
|
|
case ModeDiff:
|
|
if w.opts.Dirty[j.path] {
|
|
w.readDir(j)
|
|
return
|
|
}
|
|
if !w.opts.Touched[j.path] {
|
|
w.reuse(j, false) // nothing under here changed: keep the whole subtree, no stat at all
|
|
return
|
|
}
|
|
w.reuse(j, true) // something deeper changed: keep this list, look into the children
|
|
case ModeMTime:
|
|
var st syscall.Stat_t
|
|
if err := syscall.Lstat(j.path, &st); err != nil {
|
|
w.readDir(j)
|
|
return
|
|
}
|
|
if st.Mtim.Nano() != j.prev.MTimeNs {
|
|
w.readDir(j) // an entry was added, removed or renamed
|
|
return
|
|
}
|
|
w.reuse(j, true) // same entries; files may have grown, and subdirectories are their own question
|
|
}
|
|
}
|
|
|
|
// reuse keeps a directory as the last scan saw it. With descend, its subdirectories are
|
|
// visited (each decides for itself) and, in mtime mode, its kept files are stat'ed again.
|
|
func (w *walker) reuse(j job, descend bool) {
|
|
w.reused.Add(1)
|
|
n, p := j.node, j.prev
|
|
n.MTime, n.MTimeNs, n.SmallN, n.SmallSize, n.Denied, n.Mount = p.MTime, p.MTimeNs, p.SmallN, p.SmallSize, p.Denied, p.Mount
|
|
if !descend {
|
|
n.Children = p.Children
|
|
n.Apparent = p.Apparent - childApparent(p) // total() adds the children back
|
|
w.files.Add(int64(p.Files))
|
|
w.bytes.Add(p.Size)
|
|
w.reused.Add(int64(p.Dirs))
|
|
return
|
|
}
|
|
w.files.Add(int64(p.SmallN))
|
|
w.bytes.Add(p.SmallSize)
|
|
n.Apparent = p.Apparent - childApparent(p) // the small files' lengths; total() adds the children back
|
|
var st syscall.Stat_t
|
|
for _, c := range p.Children {
|
|
if c.Dir {
|
|
d := &Node{Name: c.Name, Dir: true, MTime: c.MTime, MTimeNs: c.MTimeNs, Mount: c.Mount}
|
|
n.Children = append(n.Children, d)
|
|
if c.Mount {
|
|
continue
|
|
}
|
|
w.push(job{path: join(j.path, c.Name), node: d, prev: c, dev: j.dev, mode: j.mode})
|
|
continue
|
|
}
|
|
if j.mode == ModeMTime {
|
|
if err := syscall.Lstat(join(j.path, c.Name), &st); err != nil {
|
|
continue // gone since (the directory's mtime would have said so, but be safe)
|
|
}
|
|
c = &Node{Name: c.Name, Size: int64(st.Blocks) * 512, Apparent: int64(st.Size), MTime: int64(st.Mtim.Sec)}
|
|
}
|
|
n.Children = append(n.Children, c)
|
|
w.files.Add(1)
|
|
w.bytes.Add(c.Size)
|
|
}
|
|
}
|
|
|
|
func childApparent(p *Node) int64 {
|
|
var a int64
|
|
for _, c := range p.Children {
|
|
a += c.Apparent
|
|
}
|
|
return a
|
|
}
|
|
|
|
func (w *walker) readDir(j job) {
|
|
w.curMu.Lock()
|
|
w.current = j.path
|
|
w.curMu.Unlock()
|
|
w.dirs.Add(1)
|
|
f, err := os.Open(j.path)
|
|
if err != nil {
|
|
j.node.Denied = true
|
|
w.denied.Add(1)
|
|
return
|
|
}
|
|
names, err := f.Readdirnames(-1)
|
|
f.Close()
|
|
if err != nil {
|
|
j.node.Denied = true
|
|
w.denied.Add(1)
|
|
}
|
|
var prevDirs map[string]*Node
|
|
if j.prev != nil {
|
|
prevDirs = map[string]*Node{}
|
|
for _, c := range j.prev.Children {
|
|
if c.Dir {
|
|
prevDirs[c.Name] = c
|
|
}
|
|
}
|
|
}
|
|
var st syscall.Stat_t
|
|
for _, name := range names {
|
|
p := join(j.path, name)
|
|
if err := syscall.Lstat(p, &st); err != nil {
|
|
continue
|
|
}
|
|
switch st.Mode & syscall.S_IFMT {
|
|
case syscall.S_IFDIR:
|
|
c := &Node{Name: name, Dir: true, MTime: int64(st.Mtim.Sec), MTimeNs: st.Mtim.Nano()}
|
|
j.node.Children = append(j.node.Children, c)
|
|
if uint64(st.Dev) != j.dev {
|
|
c.Mount = true // something else is mounted here; if it is this disk's too, it is a root of its own
|
|
continue
|
|
}
|
|
w.push(job{path: p, node: c, prev: prevDirs[name], dev: j.dev, mode: j.mode})
|
|
case syscall.S_IFREG, syscall.S_IFLNK:
|
|
alloc := int64(st.Blocks) * 512
|
|
w.files.Add(1)
|
|
w.bytes.Add(alloc)
|
|
if alloc >= KeepMin || int64(st.Size) >= KeepMin {
|
|
j.node.Children = append(j.node.Children, &Node{Name: name, Size: alloc, Apparent: int64(st.Size), MTime: int64(st.Mtim.Sec)})
|
|
} else {
|
|
j.node.SmallN++
|
|
j.node.SmallSize += alloc
|
|
j.node.Apparent += int64(st.Size) // added to the directory's own apparent total below
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// graft puts a root's tree at path under root, making any directories in between.
|
|
func graft(root *Node, path string, tree *Node) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
cur := root
|
|
for i, part := range parts {
|
|
var next *Node
|
|
for _, c := range cur.Children {
|
|
if c.Name == part && c.Dir {
|
|
next = c
|
|
break
|
|
}
|
|
}
|
|
if i == len(parts)-1 {
|
|
if next == nil {
|
|
cur.Children = append(cur.Children, tree)
|
|
} else {
|
|
// the parent's walk saw this directory as a mountpoint; the walk of the mount fills it
|
|
next.Children, next.SmallN, next.SmallSize, next.Apparent, next.Denied, next.Partial = tree.Children, tree.SmallN, tree.SmallSize, tree.Apparent, tree.Denied, tree.Partial
|
|
next.Mount = true
|
|
}
|
|
return
|
|
}
|
|
if next == nil {
|
|
next = &Node{Name: part, Dir: true}
|
|
cur.Children = append(cur.Children, next)
|
|
}
|
|
cur = next
|
|
}
|
|
}
|
|
|
|
// total sums every directory from its children and sorts them largest first.
|
|
func total(n *Node) {
|
|
if !n.Dir {
|
|
return
|
|
}
|
|
n.Size = n.SmallSize
|
|
n.Files = n.SmallN
|
|
n.Dirs = 0
|
|
apparent := n.Apparent // the small files' lengths
|
|
for _, c := range n.Children {
|
|
total(c)
|
|
n.Size += c.Size
|
|
apparent += c.Apparent
|
|
if c.Dir {
|
|
n.Dirs += 1 + c.Dirs
|
|
n.Files += c.Files
|
|
} else {
|
|
n.Files++
|
|
}
|
|
}
|
|
n.Apparent = apparent
|
|
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Size > n.Children[j].Size })
|
|
}
|
|
|
|
// Find follows path (absolute, "/" for the root) down the tree.
|
|
func Find(root *Node, path string) *Node {
|
|
cur := root
|
|
for _, part := range strings.Split(strings.Trim(filepath.Clean(path), "/"), "/") {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
var next *Node
|
|
for _, c := range cur.Children {
|
|
if c.Name == part {
|
|
next = c
|
|
break
|
|
}
|
|
}
|
|
if next == nil {
|
|
return nil
|
|
}
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
// File is one of the largest files, with its full path.
|
|
type File struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
Apparent int64 `json:"apparent"`
|
|
MTime int64 `json:"mtime"`
|
|
}
|
|
|
|
// Largest is the n biggest files in the tree, largest first.
|
|
func Largest(root *Node, n int) []File {
|
|
var out []File
|
|
minIdx := -1
|
|
var walk func(n *Node, path string)
|
|
walk = func(node *Node, path string) {
|
|
for _, c := range node.Children {
|
|
p := join(path, c.Name)
|
|
if c.Dir {
|
|
if c.Size > 0 && (len(out) < n || c.Size > out[minIdx].Size) {
|
|
walk(c, p)
|
|
}
|
|
continue
|
|
}
|
|
if len(out) < n {
|
|
out = append(out, File{Path: p, Size: c.Size, Apparent: c.Apparent, MTime: c.MTime})
|
|
if minIdx < 0 || c.Size < out[minIdx].Size {
|
|
minIdx = len(out) - 1
|
|
}
|
|
} else if c.Size > out[minIdx].Size {
|
|
out[minIdx] = File{Path: p, Size: c.Size, Apparent: c.Apparent, MTime: c.MTime}
|
|
minIdx = 0
|
|
for i := range out {
|
|
if out[i].Size < out[minIdx].Size {
|
|
minIdx = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walk(root, "/")
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Size > out[j].Size })
|
|
return out
|
|
}
|