@yuuki824/kanshi 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +34 -0
- package/Dockerfile +53 -0
- package/LICENSE +661 -0
- package/README.md +219 -0
- package/bin/kanshi.js +63 -0
- package/docker-compose.yml +84 -0
- package/go.mod +3 -0
- package/internal/access/access.go +275 -0
- package/internal/access/listeners.go +229 -0
- package/internal/config/config.go +256 -0
- package/internal/config/defaults_other.go +5 -0
- package/internal/config/defaults_windows.go +4 -0
- package/internal/config/envfile.go +147 -0
- package/internal/dockerstats/dial.go +32 -0
- package/internal/dockerstats/dial_other.go +13 -0
- package/internal/dockerstats/dial_windows.go +62 -0
- package/internal/dockerstats/dockerstats.go +626 -0
- package/internal/roots/roots.go +175 -0
- package/internal/roots/roots_linux.go +132 -0
- package/internal/roots/roots_windows.go +74 -0
- package/internal/server/access.go +155 -0
- package/internal/server/server.go +523 -0
- package/internal/server/static.go +165 -0
- package/internal/storage/cache.go +283 -0
- package/internal/storage/dir_linux.go +177 -0
- package/internal/storage/dir_windows.go +244 -0
- package/internal/storage/statat_fast.go +21 -0
- package/internal/storage/statat_other.go +11 -0
- package/internal/storage/storage.go +598 -0
- package/internal/storage/sysnum_amd64.go +7 -0
- package/internal/storage/sysnum_generic.go +7 -0
- package/internal/storage/walk.go +342 -0
- package/internal/vitals/vitals.go +246 -0
- package/internal/vitals/vitals_linux.go +548 -0
- package/internal/vitals/vitals_windows.go +286 -0
- package/main.go +129 -0
- package/main_other.go +11 -0
- package/main_windows.go +29 -0
- package/package.json +49 -0
- package/web/app.js +835 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon.svg +6 -0
- package/web/index.html +151 -0
- package/web/style.css +356 -0
- package/web/treemap.js +217 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//go:build linux && (amd64 || arm64 || riscv64)
|
|
2
|
+
|
|
3
|
+
package storage
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"syscall"
|
|
7
|
+
"unsafe"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// statAt lstats name relative to the open directory dirfd. name must be
|
|
11
|
+
// followed by a NUL in its backing array, as getdents64 names are, so it can
|
|
12
|
+
// go to the kernel as-is: no path join, no string, no allocation, and the
|
|
13
|
+
// kernel resolves one component instead of the whole path from the root.
|
|
14
|
+
func statAt(dirfd int, _, name []byte, st *syscall.Stat_t) error {
|
|
15
|
+
_, _, errno := syscall.Syscall6(sysFstatat, uintptr(dirfd), uintptr(unsafe.Pointer(&name[0])),
|
|
16
|
+
uintptr(unsafe.Pointer(st)), atSymlinkNofollow|atNoAutomount, 0, 0)
|
|
17
|
+
if errno != 0 {
|
|
18
|
+
return errno
|
|
19
|
+
}
|
|
20
|
+
return nil
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//go:build linux && !amd64 && !arm64 && !riscv64
|
|
2
|
+
|
|
3
|
+
package storage
|
|
4
|
+
|
|
5
|
+
import "syscall"
|
|
6
|
+
|
|
7
|
+
// statAt falls back to a full-path lstat on architectures where the Stat_t
|
|
8
|
+
// layout does not line up with a plain fstatat.
|
|
9
|
+
func statAt(_ int, dir, name []byte, st *syscall.Stat_t) error {
|
|
10
|
+
return syscall.Lstat(join(string(dir), string(name)), st)
|
|
11
|
+
}
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// Package storage builds a Filelight-style directory size map, one folder at
|
|
2
|
+
// a time.
|
|
3
|
+
//
|
|
4
|
+
// Opening a folder reads that one directory live, and takes each subfolder's
|
|
5
|
+
// size from a small cache file. A subfolder the cache has never seen, or has
|
|
6
|
+
// not seen for StorageInterval, is walked in the background: only that
|
|
7
|
+
// subtree, and every folder the walk passes within TreeDepth levels is cached
|
|
8
|
+
// too, so drilling further in is usually instant. Nothing is walked at
|
|
9
|
+
// startup or for a page nobody has open, and the cache outlives restarts.
|
|
10
|
+
//
|
|
11
|
+
// Sizes are what files occupy on disk (st_blocks on Linux, the allocation
|
|
12
|
+
// size on Windows), so they match `du` rather than apparent size, and
|
|
13
|
+
// hard-linked files are counted once per walk.
|
|
14
|
+
package storage
|
|
15
|
+
|
|
16
|
+
import (
|
|
17
|
+
"context"
|
|
18
|
+
"path/filepath"
|
|
19
|
+
"runtime"
|
|
20
|
+
"runtime/debug"
|
|
21
|
+
"sort"
|
|
22
|
+
"strings"
|
|
23
|
+
"sync"
|
|
24
|
+
"sync/atomic"
|
|
25
|
+
"time"
|
|
26
|
+
|
|
27
|
+
"github.com/rene-roid/kanshi/internal/config"
|
|
28
|
+
"github.com/rene-roid/kanshi/internal/roots"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/* ── wire payload ───────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
// Status is the part of the snapshot that moves while folders are sized. It
|
|
34
|
+
// rides on every live frame, so the page only refetches anything when
|
|
35
|
+
// UpdatedAt changes.
|
|
36
|
+
type Status struct {
|
|
37
|
+
Scanning bool `json:"scanning"`
|
|
38
|
+
// The folder being walked, as the host knows it, and how many wait
|
|
39
|
+
// behind it.
|
|
40
|
+
Current string `json:"current,omitempty"`
|
|
41
|
+
Queued int `json:"queued"`
|
|
42
|
+
Progress *Progress `json:"progress,omitempty"`
|
|
43
|
+
// When the last walk finished.
|
|
44
|
+
UpdatedAt *float64 `json:"updated_at"`
|
|
45
|
+
Error *string `json:"error"`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Progress is a live estimate of how far the current walk has gotten,
|
|
49
|
+
// measured against the folder's size the last time it was walked. A folder
|
|
50
|
+
// walked for the first time has nothing to compare against, so Progress is
|
|
51
|
+
// omitted rather than shipping a meaningless 0%.
|
|
52
|
+
type Progress struct {
|
|
53
|
+
Percent float64 `json:"percent"`
|
|
54
|
+
BytesDone int64 `json:"bytes_done"`
|
|
55
|
+
BytesTotal int64 `json:"bytes_total"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Root summarises one configured root.
|
|
59
|
+
type Root struct {
|
|
60
|
+
Name string `json:"name"`
|
|
61
|
+
Path string `json:"root"`
|
|
62
|
+
// Null until the root has been opened with every folder in it sized.
|
|
63
|
+
Size *int64 `json:"size"`
|
|
64
|
+
Unreadable int `json:"unreadable"`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Snapshot is what /api/storage returns. Sep is the separator root names are
|
|
68
|
+
// written with, so the page can split a pasted path and draw breadcrumbs.
|
|
69
|
+
type Snapshot struct {
|
|
70
|
+
Status
|
|
71
|
+
Roots []Root `json:"roots"`
|
|
72
|
+
Sep string `json:"sep"`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Entry is one named item in a listing.
|
|
76
|
+
type Entry struct {
|
|
77
|
+
Name string `json:"name"`
|
|
78
|
+
Size int64 `json:"size"`
|
|
79
|
+
// Waiting to be walked. Size is 0 until it has been.
|
|
80
|
+
Pending bool `json:"pending,omitempty"`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Listing is one directory: every subfolder, the largest files, and totals
|
|
84
|
+
// for the files that were counted but not named, so the parts always add up
|
|
85
|
+
// to Size once nothing is pending.
|
|
86
|
+
type Listing struct {
|
|
87
|
+
// Path is what was actually resolved, relative to the root, with "/"
|
|
88
|
+
// between names on every platform. When the requested path no longer
|
|
89
|
+
// exists it is the deepest ancestor that does, and Partial is set.
|
|
90
|
+
Path string `json:"path"`
|
|
91
|
+
Partial bool `json:"partial,omitempty"`
|
|
92
|
+
Size int64 `json:"size"`
|
|
93
|
+
Dirs []Entry `json:"dirs"`
|
|
94
|
+
Files []Entry `json:"files"`
|
|
95
|
+
// All files directly inside, including the ones named in Files.
|
|
96
|
+
FileCount int `json:"file_count"`
|
|
97
|
+
FileBytes int64 `json:"file_bytes"`
|
|
98
|
+
// Subfolders that have no size yet.
|
|
99
|
+
Pending int `json:"pending"`
|
|
100
|
+
// Directories in here that could not be entered, so were not counted.
|
|
101
|
+
Unreadable int `json:"unreadable"`
|
|
102
|
+
// When the oldest subfolder size shown was measured; null when none is.
|
|
103
|
+
SizedAt *float64 `json:"sized_at"`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── scanner ────────────────────────────────────────────────────────────── */
|
|
107
|
+
|
|
108
|
+
// job is one folder waiting to be walked.
|
|
109
|
+
type job struct {
|
|
110
|
+
path string // as read, links resolved
|
|
111
|
+
key string // roots.Key(path), the cache key
|
|
112
|
+
label string // as the host knows it
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Scanner answers listings from the disk and the cache, and walks folders
|
|
116
|
+
// the cache is missing one at a time, in the background.
|
|
117
|
+
type Scanner struct {
|
|
118
|
+
cfg config.Config
|
|
119
|
+
roots *roots.Resolver
|
|
120
|
+
cache *cache
|
|
121
|
+
logf func(string, ...any)
|
|
122
|
+
|
|
123
|
+
mu sync.Mutex
|
|
124
|
+
queue []job // most urgent first
|
|
125
|
+
current *job
|
|
126
|
+
updatedAt *float64
|
|
127
|
+
err *string
|
|
128
|
+
|
|
129
|
+
// progressDone changes once per directory, far more often than anything
|
|
130
|
+
// else here, so it and its total are plain atomics rather than going
|
|
131
|
+
// through mu, which every live frame takes.
|
|
132
|
+
progressDone atomic.Int64
|
|
133
|
+
progressTotal atomic.Int64
|
|
134
|
+
|
|
135
|
+
// wake tells Loop there is work. Buffered by one so a signal is never
|
|
136
|
+
// lost and no sender ever blocks.
|
|
137
|
+
wake chan struct{}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// New loads the cache at cfg.StorageCache. A cache that cannot be written is
|
|
141
|
+
// not fatal: sizes are then kept in memory, and walked again after a restart.
|
|
142
|
+
func New(cfg config.Config, r *roots.Resolver, logf func(string, ...any)) *Scanner {
|
|
143
|
+
s := &Scanner{cfg: cfg, roots: r, logf: logf, wake: make(chan struct{}, 1)}
|
|
144
|
+
c, err := openCache(cfg.StorageCache)
|
|
145
|
+
if err != nil {
|
|
146
|
+
logf("storage cache %s: %v — folder sizes will not survive a restart", cfg.StorageCache, err)
|
|
147
|
+
c, _ = openCache("") // in memory, which cannot fail
|
|
148
|
+
}
|
|
149
|
+
s.cache = c
|
|
150
|
+
return s
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Close saves what listings added to the cache since the last walk.
|
|
154
|
+
func (s *Scanner) Close() error { return s.cache.close() }
|
|
155
|
+
|
|
156
|
+
// Status returns the queue's state plus a live progress estimate for the
|
|
157
|
+
// current walk. It is cheap enough to call on every live tick.
|
|
158
|
+
func (s *Scanner) Status() Status {
|
|
159
|
+
s.mu.Lock()
|
|
160
|
+
defer s.mu.Unlock()
|
|
161
|
+
out := Status{Queued: len(s.queue), UpdatedAt: s.updatedAt, Error: s.err}
|
|
162
|
+
if s.current != nil {
|
|
163
|
+
out.Scanning, out.Current = true, s.current.label
|
|
164
|
+
if total := s.progressTotal.Load(); total > 0 {
|
|
165
|
+
done := s.progressDone.Load()
|
|
166
|
+
pct := round1(min(100, float64(done)/float64(total)*100))
|
|
167
|
+
out.Progress = &Progress{Percent: pct, BytesDone: done, BytesTotal: total}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
out.Scanning = out.Scanning || out.Queued > 0
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// rootDir is a configured root that is there right now.
|
|
175
|
+
type rootDir struct {
|
|
176
|
+
label string
|
|
177
|
+
path string // as configured
|
|
178
|
+
real string // with links resolved, since the walk never follows them
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func (s *Scanner) rootDirs() []rootDir {
|
|
182
|
+
var out []rootDir
|
|
183
|
+
for _, r := range s.roots.Roots() {
|
|
184
|
+
real := r.Path
|
|
185
|
+
if p, err := filepath.EvalSymlinks(r.Path); err == nil {
|
|
186
|
+
real = p
|
|
187
|
+
}
|
|
188
|
+
if _, err := rootDevice(real); err != nil {
|
|
189
|
+
continue // not mounted, or not a directory: leave it out
|
|
190
|
+
}
|
|
191
|
+
out = append(out, rootDir{label: r.Label, path: r.Path, real: real})
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func (s *Scanner) Snapshot() Snapshot {
|
|
197
|
+
out := Snapshot{Status: s.Status(), Roots: []Root{}, Sep: roots.Sep}
|
|
198
|
+
for _, r := range s.rootDirs() {
|
|
199
|
+
root := Root{Name: r.label, Path: r.path}
|
|
200
|
+
if c, ok := s.cache.get(roots.Key(r.real)); ok {
|
|
201
|
+
root.Size, root.Unreadable = &c.size, c.unreadable
|
|
202
|
+
}
|
|
203
|
+
out.Roots = append(out.Roots, root)
|
|
204
|
+
}
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ── listing ────────────────────────────────────────────────────────────── */
|
|
209
|
+
|
|
210
|
+
// opened is a directory that has just been read.
|
|
211
|
+
type opened struct {
|
|
212
|
+
root rootDir
|
|
213
|
+
dir string
|
|
214
|
+
resolved []string
|
|
215
|
+
partial bool
|
|
216
|
+
locked bool // the root itself could not be read
|
|
217
|
+
dev uint64
|
|
218
|
+
entries []dirEntry // valid until rd reads again
|
|
219
|
+
subdirs []string // the entries a walk would enter
|
|
220
|
+
excluded map[string]bool
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// open resolves rel ("home/yuuki", or "" for the root itself) under root
|
|
224
|
+
// number rootIdx by reading each directory on the way down, so a path can
|
|
225
|
+
// only name what a walk would reach: no "..", no links, nothing excluded and
|
|
226
|
+
// no other filesystem. A path that has since disappeared resolves to its
|
|
227
|
+
// deepest surviving ancestor instead.
|
|
228
|
+
func (s *Scanner) open(rd *dirReader, rootIdx int, rel string) (*opened, bool) {
|
|
229
|
+
list := s.rootDirs()
|
|
230
|
+
if rootIdx < 0 || rootIdx >= len(list) {
|
|
231
|
+
return nil, false
|
|
232
|
+
}
|
|
233
|
+
o := &opened{root: list[rootIdx], dir: list[rootIdx].real, excluded: s.excludes()}
|
|
234
|
+
dev, err := rootDevice(o.dir)
|
|
235
|
+
if err != nil {
|
|
236
|
+
return nil, false
|
|
237
|
+
}
|
|
238
|
+
o.dev = dev
|
|
239
|
+
if o.entries, _, err = rd.read(cstring(o.dir), dedupeIn(o.dir)); err != nil {
|
|
240
|
+
o.locked = true
|
|
241
|
+
return o, true
|
|
242
|
+
}
|
|
243
|
+
for _, seg := range strings.Split(rel, "/") {
|
|
244
|
+
if seg == "" {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
name, ok := o.find(seg)
|
|
248
|
+
if !ok {
|
|
249
|
+
o.partial = true
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
next := joinPath(o.dir, name)
|
|
253
|
+
entries, _, err := rd.read(cstring(next), dedupeIn(next))
|
|
254
|
+
if err != nil {
|
|
255
|
+
o.partial = true
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
o.dir, o.entries = next, entries
|
|
259
|
+
o.resolved = append(o.resolved, name)
|
|
260
|
+
}
|
|
261
|
+
for _, e := range o.entries {
|
|
262
|
+
if o.walkable(e) {
|
|
263
|
+
o.subdirs = append(o.subdirs, string(e.name))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return o, true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// walkable reports whether a walk of o.dir would enter e.
|
|
270
|
+
func (o *opened) walkable(e dirEntry) bool {
|
|
271
|
+
if !e.dir || e.dev != o.dev {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
return len(o.excluded) == 0 || !o.excluded[roots.Key(joinPath(o.dir, string(e.name)))]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func (o *opened) find(seg string) (string, bool) {
|
|
278
|
+
for _, e := range o.entries {
|
|
279
|
+
if sameName(string(e.name), seg) && o.walkable(e) {
|
|
280
|
+
return string(e.name), true
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return "", false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// List reads one directory and sizes its subfolders from the cache, queueing
|
|
287
|
+
// a walk for each one the cache is missing or has let go stale. It reports
|
|
288
|
+
// false only when there is no such root.
|
|
289
|
+
func (s *Scanner) List(rootIdx int, rel string) (Listing, bool) {
|
|
290
|
+
rd := newDirReader()
|
|
291
|
+
o, ok := s.open(rd, rootIdx, rel)
|
|
292
|
+
if !ok {
|
|
293
|
+
return Listing{}, false
|
|
294
|
+
}
|
|
295
|
+
out := Listing{Path: strings.Join(o.resolved, "/"), Partial: o.partial, Dirs: []Entry{}, Files: []Entry{}}
|
|
296
|
+
if o.locked {
|
|
297
|
+
out.Unreadable = 1
|
|
298
|
+
return out, true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
var top fileHeap
|
|
302
|
+
var links map[uint64]struct{}
|
|
303
|
+
for _, e := range o.entries {
|
|
304
|
+
if e.dir {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if e.key != 0 {
|
|
308
|
+
if links == nil {
|
|
309
|
+
links = map[uint64]struct{}{}
|
|
310
|
+
}
|
|
311
|
+
if _, dup := links[e.key]; dup {
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
links[e.key] = struct{}{}
|
|
315
|
+
}
|
|
316
|
+
out.FileCount++
|
|
317
|
+
out.FileBytes += e.size
|
|
318
|
+
top.offer(e.size, e.name)
|
|
319
|
+
}
|
|
320
|
+
sort.Slice(top, func(a, b int) bool { return top[a].size > top[b].size })
|
|
321
|
+
for _, f := range top {
|
|
322
|
+
out.Files = append(out.Files, Entry{Name: f.name, Size: f.size})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
dirKey := roots.Key(o.dir)
|
|
326
|
+
cached := s.cache.children(dirKey)
|
|
327
|
+
present := make(map[string]bool, len(o.subdirs))
|
|
328
|
+
var missing, stale []job
|
|
329
|
+
var oldest time.Time
|
|
330
|
+
now := time.Now()
|
|
331
|
+
for _, name := range o.subdirs {
|
|
332
|
+
nk := nameKey(name)
|
|
333
|
+
present[nk] = true
|
|
334
|
+
c, ok := cached[nk]
|
|
335
|
+
if !ok {
|
|
336
|
+
out.Dirs = append(out.Dirs, Entry{Name: name, Pending: true})
|
|
337
|
+
out.Pending++
|
|
338
|
+
missing = append(missing, s.job(o.dir, name))
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
out.Dirs = append(out.Dirs, Entry{Name: name, Size: c.size})
|
|
342
|
+
out.Size += c.size
|
|
343
|
+
out.Unreadable += c.unreadable
|
|
344
|
+
if oldest.IsZero() || c.at.Before(oldest) {
|
|
345
|
+
oldest = c.at
|
|
346
|
+
}
|
|
347
|
+
if s.cfg.StorageInterval > 0 && now.Sub(c.at) > s.cfg.StorageInterval {
|
|
348
|
+
stale = append(stale, s.job(o.dir, name))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
out.Size += out.FileBytes
|
|
352
|
+
sort.SliceStable(out.Dirs, func(a, b int) bool { return out.Dirs[a].Size > out.Dirs[b].Size })
|
|
353
|
+
if !oldest.IsZero() {
|
|
354
|
+
at := float64(oldest.Unix())
|
|
355
|
+
out.SizedAt = &at
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
s.cache.prune(dirKey, cached, present, s.rootKeys())
|
|
359
|
+
// Fully sized, this folder's total is now as fresh as its oldest part, and
|
|
360
|
+
// its parent's listing picks that up from the cache.
|
|
361
|
+
if out.Pending == 0 {
|
|
362
|
+
if oldest.IsZero() {
|
|
363
|
+
oldest = now
|
|
364
|
+
}
|
|
365
|
+
s.cache.put(dirKey, sized{size: out.Size, unreadable: out.Unreadable, at: oldest})
|
|
366
|
+
}
|
|
367
|
+
s.enqueue(missing, true)
|
|
368
|
+
s.enqueue(stale, false)
|
|
369
|
+
return out, true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Rescan re-walks every subfolder of one directory that was not sized in the
|
|
373
|
+
// last StorageMinRescan, ahead of anything already queued.
|
|
374
|
+
func (s *Scanner) Rescan(rootIdx int, rel string) (Status, bool) {
|
|
375
|
+
o, ok := s.open(newDirReader(), rootIdx, rel)
|
|
376
|
+
if !ok {
|
|
377
|
+
return Status{}, false
|
|
378
|
+
}
|
|
379
|
+
cached := s.cache.children(roots.Key(o.dir))
|
|
380
|
+
var jobs []job
|
|
381
|
+
for _, name := range o.subdirs {
|
|
382
|
+
if c, ok := cached[nameKey(name)]; ok && time.Since(c.at) < s.cfg.StorageMinRescan {
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
jobs = append(jobs, s.job(o.dir, name))
|
|
386
|
+
}
|
|
387
|
+
s.enqueue(jobs, true)
|
|
388
|
+
return s.Status(), true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
func (s *Scanner) job(dir, name string) job {
|
|
392
|
+
p := joinPath(dir, name)
|
|
393
|
+
return job{path: p, key: roots.Key(p), label: s.roots.HostPath(p)}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ── the walk queue ─────────────────────────────────────────────────────── */
|
|
397
|
+
|
|
398
|
+
// enqueue adds folders to walk. A folder inside one that is already queued or
|
|
399
|
+
// being walked is left to that walk, and queued folders inside a new one are
|
|
400
|
+
// dropped in its favour. Urgent jobs, the ones a listing is waiting on, go to
|
|
401
|
+
// the front, and so does a queued folder that already covers one.
|
|
402
|
+
func (s *Scanner) enqueue(jobs []job, urgent bool) {
|
|
403
|
+
if len(jobs) == 0 {
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
s.mu.Lock()
|
|
407
|
+
var head []job
|
|
408
|
+
for _, j := range jobs {
|
|
409
|
+
if s.current != nil && roots.Within(s.current.key, j.key) {
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
covered := -1
|
|
413
|
+
for i, q := range s.queue {
|
|
414
|
+
if roots.Within(q.key, j.key) {
|
|
415
|
+
covered = i
|
|
416
|
+
break
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if covered >= 0 {
|
|
420
|
+
if urgent {
|
|
421
|
+
head = append(head, s.queue[covered])
|
|
422
|
+
s.queue = append(s.queue[:covered], s.queue[covered+1:]...)
|
|
423
|
+
}
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
kept := s.queue[:0]
|
|
427
|
+
for _, q := range s.queue {
|
|
428
|
+
if !roots.Within(j.key, q.key) {
|
|
429
|
+
kept = append(kept, q)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
s.queue = kept
|
|
433
|
+
if urgent {
|
|
434
|
+
head = append(head, j)
|
|
435
|
+
} else {
|
|
436
|
+
s.queue = append(s.queue, j)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
s.queue = append(head, s.queue...)
|
|
440
|
+
s.mu.Unlock()
|
|
441
|
+
select {
|
|
442
|
+
case s.wake <- struct{}{}:
|
|
443
|
+
default: // already pending
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// next takes the most urgent job off the queue, or reports that there is
|
|
448
|
+
// none left.
|
|
449
|
+
func (s *Scanner) next() (job, bool) {
|
|
450
|
+
s.mu.Lock()
|
|
451
|
+
if len(s.queue) == 0 {
|
|
452
|
+
s.current = nil
|
|
453
|
+
s.mu.Unlock()
|
|
454
|
+
return job{}, false
|
|
455
|
+
}
|
|
456
|
+
j := s.queue[0]
|
|
457
|
+
s.queue = s.queue[1:]
|
|
458
|
+
s.mu.Unlock()
|
|
459
|
+
|
|
460
|
+
var total int64
|
|
461
|
+
if c, ok := s.cache.get(j.key); ok {
|
|
462
|
+
total = c.size
|
|
463
|
+
}
|
|
464
|
+
s.progressTotal.Store(total)
|
|
465
|
+
s.progressDone.Store(0)
|
|
466
|
+
s.mu.Lock()
|
|
467
|
+
s.current = &j
|
|
468
|
+
s.mu.Unlock()
|
|
469
|
+
return j, true
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Loop walks queued folders as they arrive. Listings are what fill the
|
|
473
|
+
// queue, so with nobody looking it sleeps.
|
|
474
|
+
func (s *Scanner) Loop(ctx context.Context) {
|
|
475
|
+
for {
|
|
476
|
+
select {
|
|
477
|
+
case <-ctx.Done():
|
|
478
|
+
return
|
|
479
|
+
case <-s.wake:
|
|
480
|
+
}
|
|
481
|
+
s.drain(ctx)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// drain walks until the queue is empty.
|
|
486
|
+
//
|
|
487
|
+
// Linux applies setpriority(PRIO_PROCESS) and ioprio_set to the calling
|
|
488
|
+
// thread, and Windows' background mode is per thread too, so the goroutine is
|
|
489
|
+
// pinned first. It never unlocks: the runtime then retires the deprioritised
|
|
490
|
+
// thread when the queue runs dry, instead of handing it back to the scheduler
|
|
491
|
+
// for the poller to land on.
|
|
492
|
+
func (s *Scanner) drain(ctx context.Context) {
|
|
493
|
+
done := make(chan struct{})
|
|
494
|
+
go func() {
|
|
495
|
+
defer close(done)
|
|
496
|
+
runtime.LockOSThread()
|
|
497
|
+
deprioritise()
|
|
498
|
+
w := &walker{
|
|
499
|
+
maxDepth: s.cfg.TreeDepth,
|
|
500
|
+
excluded: s.excludes(),
|
|
501
|
+
progress: &s.progressDone,
|
|
502
|
+
pace: newThrottle(s.cfg.StorageCPU),
|
|
503
|
+
rd: newDirReader(),
|
|
504
|
+
}
|
|
505
|
+
for ctx.Err() == nil {
|
|
506
|
+
j, ok := s.next()
|
|
507
|
+
if !ok {
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
s.walkOne(ctx, w, j)
|
|
511
|
+
}
|
|
512
|
+
}()
|
|
513
|
+
select {
|
|
514
|
+
case <-done:
|
|
515
|
+
case <-ctx.Done():
|
|
516
|
+
}
|
|
517
|
+
// A walk's scratch space (the inode set, the BFS frontier) dwarfs what is
|
|
518
|
+
// kept. Hand it back to the kernel now instead of letting it sit in the
|
|
519
|
+
// heap until the next one.
|
|
520
|
+
debug.FreeOSMemory()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
func (s *Scanner) walkOne(ctx context.Context, w *walker, j job) {
|
|
524
|
+
started := time.Now()
|
|
525
|
+
t, err := w.walk(ctx, j.path)
|
|
526
|
+
if ctx.Err() != nil {
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
if err != nil {
|
|
530
|
+
// Gone, or not a directory any more. Recording it as an empty,
|
|
531
|
+
// unreadable folder keeps a listing that still shows it from asking
|
|
532
|
+
// for it again and again.
|
|
533
|
+
t = &tree{nodes: []*dirNode{{name: baseName(j.path), unreadable: 1}}}
|
|
534
|
+
}
|
|
535
|
+
err = s.cache.store(j.key, t, started, s.rootKeys())
|
|
536
|
+
|
|
537
|
+
at := float64(time.Now().UnixNano()) / 1e9
|
|
538
|
+
s.mu.Lock()
|
|
539
|
+
s.updatedAt = &at
|
|
540
|
+
if err != nil {
|
|
541
|
+
msg := "storage cache: " + err.Error()
|
|
542
|
+
s.err = &msg
|
|
543
|
+
} else {
|
|
544
|
+
s.err = nil
|
|
545
|
+
}
|
|
546
|
+
s.mu.Unlock()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ── helpers ────────────────────────────────────────────────────────────── */
|
|
550
|
+
|
|
551
|
+
// rootKeys is every root's cache key, which a walk or prune elsewhere must
|
|
552
|
+
// not delete.
|
|
553
|
+
func (s *Scanner) rootKeys() []string {
|
|
554
|
+
var out []string
|
|
555
|
+
for _, r := range s.rootDirs() {
|
|
556
|
+
out = append(out, roots.Key(r.real))
|
|
557
|
+
}
|
|
558
|
+
return out
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// excludes normalises KANSHI_STORAGE_EXCLUDE. Entries may be written as host
|
|
562
|
+
// paths or, inside a container, as the paths the container sees; both forms
|
|
563
|
+
// are matched.
|
|
564
|
+
func (s *Scanner) excludes() map[string]bool {
|
|
565
|
+
out := make(map[string]bool, 2*len(s.cfg.StorageExclude))
|
|
566
|
+
for _, p := range s.cfg.StorageExclude {
|
|
567
|
+
out[roots.Key(p)] = true
|
|
568
|
+
out[roots.Key(s.roots.ContainerPath(p))] = true
|
|
569
|
+
}
|
|
570
|
+
return out
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// dedupeIn reports whether files in dir should be counted once per file ID,
|
|
574
|
+
// by the same rule as a walk: Windows only does it inside its system
|
|
575
|
+
// directory, and Linux always does, by link count.
|
|
576
|
+
func dedupeIn(dir string) bool {
|
|
577
|
+
sys := systemRoot()
|
|
578
|
+
return sys != "" && roots.Within(sys, dir)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// joinPath adds one name to a directory path read from disk.
|
|
582
|
+
func joinPath(dir, name string) string {
|
|
583
|
+
if dir[len(dir)-1] == pathSep {
|
|
584
|
+
return dir + name
|
|
585
|
+
}
|
|
586
|
+
return dir + string(pathSep) + name
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// cstring is path with a NUL after it in the backing array, as the directory
|
|
590
|
+
// readers require.
|
|
591
|
+
func cstring(path string) []byte {
|
|
592
|
+
b := append([]byte(path), 0)
|
|
593
|
+
return b[:len(path)]
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
func round1(v float64) float64 {
|
|
597
|
+
return float64(int64(v*10+0.5)) / 10
|
|
598
|
+
}
|