@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.
Files changed (45) hide show
  1. package/.env.example +34 -0
  2. package/Dockerfile +53 -0
  3. package/LICENSE +661 -0
  4. package/README.md +219 -0
  5. package/bin/kanshi.js +63 -0
  6. package/docker-compose.yml +84 -0
  7. package/go.mod +3 -0
  8. package/internal/access/access.go +275 -0
  9. package/internal/access/listeners.go +229 -0
  10. package/internal/config/config.go +256 -0
  11. package/internal/config/defaults_other.go +5 -0
  12. package/internal/config/defaults_windows.go +4 -0
  13. package/internal/config/envfile.go +147 -0
  14. package/internal/dockerstats/dial.go +32 -0
  15. package/internal/dockerstats/dial_other.go +13 -0
  16. package/internal/dockerstats/dial_windows.go +62 -0
  17. package/internal/dockerstats/dockerstats.go +626 -0
  18. package/internal/roots/roots.go +175 -0
  19. package/internal/roots/roots_linux.go +132 -0
  20. package/internal/roots/roots_windows.go +74 -0
  21. package/internal/server/access.go +155 -0
  22. package/internal/server/server.go +523 -0
  23. package/internal/server/static.go +165 -0
  24. package/internal/storage/cache.go +283 -0
  25. package/internal/storage/dir_linux.go +177 -0
  26. package/internal/storage/dir_windows.go +244 -0
  27. package/internal/storage/statat_fast.go +21 -0
  28. package/internal/storage/statat_other.go +11 -0
  29. package/internal/storage/storage.go +598 -0
  30. package/internal/storage/sysnum_amd64.go +7 -0
  31. package/internal/storage/sysnum_generic.go +7 -0
  32. package/internal/storage/walk.go +342 -0
  33. package/internal/vitals/vitals.go +246 -0
  34. package/internal/vitals/vitals_linux.go +548 -0
  35. package/internal/vitals/vitals_windows.go +286 -0
  36. package/main.go +129 -0
  37. package/main_other.go +11 -0
  38. package/main_windows.go +29 -0
  39. package/package.json +49 -0
  40. package/web/app.js +835 -0
  41. package/web/apple-touch-icon.png +0 -0
  42. package/web/icon.svg +6 -0
  43. package/web/index.html +151 -0
  44. package/web/style.css +356 -0
  45. 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
+ }
@@ -0,0 +1,7 @@
1
+ //go:build linux
2
+
3
+ package storage
4
+
5
+ import "syscall"
6
+
7
+ const sysFstatat = syscall.SYS_NEWFSTATAT
@@ -0,0 +1,7 @@
1
+ //go:build linux && (arm64 || riscv64)
2
+
3
+ package storage
4
+
5
+ import "syscall"
6
+
7
+ const sysFstatat = syscall.SYS_FSTATAT