mirror of
https://github.com/tw93/Mole.git
synced 2026-02-11 22:19:00 +00:00
Merge branch 'main' into dev
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -414,6 +415,221 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCacheReusesRecentEntryAfterDirectoryChanges(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
target := filepath.Join(home, "recent-change-target")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
result := scanResult{TotalSize: 5, TotalFiles: 1}
|
||||
if err := saveCacheToDisk(target, result); err != nil {
|
||||
t.Fatalf("saveCacheToDisk: %v", err)
|
||||
}
|
||||
|
||||
cachePath, err := getCachePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("getCachePath: %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open cache: %v", err)
|
||||
}
|
||||
var entry cacheEntry
|
||||
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||
t.Fatalf("decode cache: %v", err)
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
// Make cache entry look recently scanned, but older than mod time grace.
|
||||
entry.ModTime = time.Now().Add(-2 * time.Hour)
|
||||
entry.ScanTime = time.Now().Add(-1 * time.Hour)
|
||||
|
||||
tmp := cachePath + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("create tmp cache: %v", err)
|
||||
}
|
||||
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||
t.Fatalf("encode tmp cache: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
t.Fatalf("rename tmp cache: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
||||
t.Fatalf("chtimes target: %v", err)
|
||||
}
|
||||
|
||||
if _, err := loadCacheFromDisk(target); err != nil {
|
||||
t.Fatalf("expected recent cache to be reused, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCacheExpiresWhenModifiedAndReuseWindowPassed(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
target := filepath.Join(home, "reuse-window-target")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
result := scanResult{TotalSize: 5, TotalFiles: 1}
|
||||
if err := saveCacheToDisk(target, result); err != nil {
|
||||
t.Fatalf("saveCacheToDisk: %v", err)
|
||||
}
|
||||
|
||||
cachePath, err := getCachePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("getCachePath: %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open cache: %v", err)
|
||||
}
|
||||
var entry cacheEntry
|
||||
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||
t.Fatalf("decode cache: %v", err)
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
// Within overall 7-day TTL but beyond reuse window.
|
||||
entry.ModTime = time.Now().Add(-48 * time.Hour)
|
||||
entry.ScanTime = time.Now().Add(-(cacheReuseWindow + time.Hour))
|
||||
|
||||
tmp := cachePath + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("create tmp cache: %v", err)
|
||||
}
|
||||
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||
t.Fatalf("encode tmp cache: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
t.Fatalf("rename tmp cache: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
||||
t.Fatalf("chtimes target: %v", err)
|
||||
}
|
||||
|
||||
if _, err := loadCacheFromDisk(target); err == nil {
|
||||
t.Fatalf("expected cache load to fail after reuse window passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStaleCacheFromDiskAllowsRecentExpiredCache(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
target := filepath.Join(home, "stale-cache-target")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
result := scanResult{TotalSize: 7, TotalFiles: 2}
|
||||
if err := saveCacheToDisk(target, result); err != nil {
|
||||
t.Fatalf("saveCacheToDisk: %v", err)
|
||||
}
|
||||
|
||||
cachePath, err := getCachePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("getCachePath: %v", err)
|
||||
}
|
||||
file, err := os.Open(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open cache: %v", err)
|
||||
}
|
||||
var entry cacheEntry
|
||||
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||
t.Fatalf("decode cache: %v", err)
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
// Expired for normal cache validation but still inside stale fallback window.
|
||||
entry.ModTime = time.Now().Add(-48 * time.Hour)
|
||||
entry.ScanTime = time.Now().Add(-48 * time.Hour)
|
||||
|
||||
tmp := cachePath + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("create tmp cache: %v", err)
|
||||
}
|
||||
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||
t.Fatalf("encode tmp cache: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
t.Fatalf("rename tmp cache: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
||||
t.Fatalf("chtimes target: %v", err)
|
||||
}
|
||||
|
||||
if _, err := loadCacheFromDisk(target); err == nil {
|
||||
t.Fatalf("expected normal cache load to fail")
|
||||
}
|
||||
if _, err := loadStaleCacheFromDisk(target); err != nil {
|
||||
t.Fatalf("expected stale cache load to succeed, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStaleCacheFromDiskExpiresByStaleTTL(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
target := filepath.Join(home, "stale-cache-expired-target")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
result := scanResult{TotalSize: 9, TotalFiles: 3}
|
||||
if err := saveCacheToDisk(target, result); err != nil {
|
||||
t.Fatalf("saveCacheToDisk: %v", err)
|
||||
}
|
||||
|
||||
cachePath, err := getCachePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("getCachePath: %v", err)
|
||||
}
|
||||
file, err := os.Open(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open cache: %v", err)
|
||||
}
|
||||
var entry cacheEntry
|
||||
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||
t.Fatalf("decode cache: %v", err)
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
entry.ScanTime = time.Now().Add(-(staleCacheTTL + time.Hour))
|
||||
|
||||
tmp := cachePath + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("create tmp cache: %v", err)
|
||||
}
|
||||
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||
t.Fatalf("encode tmp cache: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
t.Fatalf("rename tmp cache: %v", err)
|
||||
}
|
||||
|
||||
if _, err := loadStaleCacheFromDisk(target); err == nil {
|
||||
t.Fatalf("expected stale cache load to fail after stale TTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPathPermissionError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
lockedDir := filepath.Join(root, "locked")
|
||||
@@ -448,3 +664,40 @@ func TestScanPathPermissionError(t *testing.T) {
|
||||
t.Logf("unexpected error type: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDirSizeFastHighFanoutCompletes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// Reproduce high fan-out nested directory pattern that previously risked semaphore deadlock.
|
||||
const fanout = 256
|
||||
for i := 0; i < fanout; i++ {
|
||||
nested := filepath.Join(root, fmt.Sprintf("dir-%03d", i), "nested")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatalf("create nested dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "data.bin"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write nested file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var files, dirs, bytes int64
|
||||
current := &atomic.Value{}
|
||||
current.Store("")
|
||||
|
||||
done := make(chan int64, 1)
|
||||
go func() {
|
||||
done <- calculateDirSizeFast(root, &files, &dirs, &bytes, current)
|
||||
}()
|
||||
|
||||
select {
|
||||
case total := <-done:
|
||||
if total <= 0 {
|
||||
t.Fatalf("expected positive total size, got %d", total)
|
||||
}
|
||||
if got := atomic.LoadInt64(&files); got < fanout {
|
||||
t.Fatalf("expected at least %d files scanned, got %d", fanout, got)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("calculateDirSizeFast did not complete under high fan-out")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func getCachePath(path string) (string, error) {
|
||||
return filepath.Join(cacheDir, filename), nil
|
||||
}
|
||||
|
||||
func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
||||
func loadRawCacheFromDisk(path string) (*cacheEntry, error) {
|
||||
cachePath, err := getCachePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -200,23 +200,56 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
||||
entry, err := loadRawCacheFromDisk(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info.ModTime().After(entry.ModTime) {
|
||||
// Allow grace window.
|
||||
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
|
||||
return nil, fmt.Errorf("cache expired: directory modified")
|
||||
}
|
||||
}
|
||||
|
||||
if time.Since(entry.ScanTime) > 7*24*time.Hour {
|
||||
scanAge := time.Since(entry.ScanTime)
|
||||
if scanAge > 7*24*time.Hour {
|
||||
return nil, fmt.Errorf("cache expired: too old")
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
if info.ModTime().After(entry.ModTime) {
|
||||
// Allow grace window.
|
||||
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
|
||||
// Directory mod time is noisy on macOS; reuse recent cache to avoid
|
||||
// frequent full rescans while still forcing refresh for older entries.
|
||||
if cacheReuseWindow <= 0 || scanAge > cacheReuseWindow {
|
||||
return nil, fmt.Errorf("cache expired: directory modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// loadStaleCacheFromDisk loads cache without strict freshness checks.
|
||||
// It is used for fast first paint before triggering a background refresh.
|
||||
func loadStaleCacheFromDisk(path string) (*cacheEntry, error) {
|
||||
entry, err := loadRawCacheFromDisk(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if time.Since(entry.ScanTime) > staleCacheTTL {
|
||||
return nil, fmt.Errorf("stale cache expired")
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func saveCacheToDisk(path string, result scanResult) error {
|
||||
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
maxConcurrentOverview = 8
|
||||
batchUpdateSize = 100
|
||||
cacheModTimeGrace = 30 * time.Minute
|
||||
cacheReuseWindow = 24 * time.Hour
|
||||
staleCacheTTL = 3 * 24 * time.Hour
|
||||
|
||||
// Worker pool limits.
|
||||
minWorkers = 16
|
||||
@@ -187,6 +189,17 @@ var defaultSkipDirs = map[string]bool{
|
||||
"nfs": true,
|
||||
"PHD": true,
|
||||
"Permissions": true,
|
||||
|
||||
// Virtualization/Container mounts (NFS, network filesystems).
|
||||
"OrbStack": true, // OrbStack NFS mounts
|
||||
"Colima": true, // Colima VM mounts
|
||||
"Parallels": true, // Parallels Desktop VMs
|
||||
"VMware Fusion": true, // VMware Fusion VMs
|
||||
"VirtualBox VMs": true, // VirtualBox VMs
|
||||
"Rancher Desktop": true, // Rancher Desktop mounts
|
||||
".lima": true, // Lima VM mounts
|
||||
".colima": true, // Colima config/mounts
|
||||
".orbstack": true, // OrbStack config/mounts
|
||||
}
|
||||
|
||||
var skipExtensions = map[string]bool{
|
||||
|
||||
@@ -63,8 +63,10 @@ type historyEntry struct {
|
||||
}
|
||||
|
||||
type scanResultMsg struct {
|
||||
path string
|
||||
result scanResult
|
||||
err error
|
||||
stale bool
|
||||
}
|
||||
|
||||
type overviewSizeMsg struct {
|
||||
@@ -369,9 +371,19 @@ func (m model) scanCmd(path string) tea.Cmd {
|
||||
Entries: cached.Entries,
|
||||
LargeFiles: cached.LargeFiles,
|
||||
TotalSize: cached.TotalSize,
|
||||
TotalFiles: 0, // Cache doesn't store file count currently, minor UI limitation
|
||||
TotalFiles: cached.TotalFiles,
|
||||
}
|
||||
return scanResultMsg{result: result, err: nil}
|
||||
return scanResultMsg{path: path, result: result, err: nil}
|
||||
}
|
||||
|
||||
if stale, err := loadStaleCacheFromDisk(path); err == nil {
|
||||
result := scanResult{
|
||||
Entries: stale.Entries,
|
||||
LargeFiles: stale.LargeFiles,
|
||||
TotalSize: stale.TotalSize,
|
||||
TotalFiles: stale.TotalFiles,
|
||||
}
|
||||
return scanResultMsg{path: path, result: result, err: nil, stale: true}
|
||||
}
|
||||
|
||||
v, err, _ := scanGroup.Do(path, func() (any, error) {
|
||||
@@ -379,7 +391,7 @@ func (m model) scanCmd(path string) tea.Cmd {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return scanResultMsg{err: err}
|
||||
return scanResultMsg{path: path, err: err}
|
||||
}
|
||||
|
||||
result := v.(scanResult)
|
||||
@@ -390,7 +402,28 @@ func (m model) scanCmd(path string) tea.Cmd {
|
||||
}
|
||||
}(path, result)
|
||||
|
||||
return scanResultMsg{result: result, err: nil}
|
||||
return scanResultMsg{path: path, result: result, err: nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) scanFreshCmd(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
v, err, _ := scanGroup.Do(path, func() (any, error) {
|
||||
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return scanResultMsg{path: path, err: err}
|
||||
}
|
||||
|
||||
result := v.(scanResult)
|
||||
go func(p string, r scanResult) {
|
||||
if err := saveCacheToDisk(p, r); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}(path, result)
|
||||
|
||||
return scanResultMsg{path: path, result: result}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +475,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case scanResultMsg:
|
||||
if msg.path != "" && msg.path != m.path {
|
||||
return m, nil
|
||||
}
|
||||
m.scanning = false
|
||||
if msg.err != nil {
|
||||
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
||||
@@ -457,7 +493,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.largeFiles = msg.result.LargeFiles
|
||||
m.totalSize = msg.result.TotalSize
|
||||
m.totalFiles = msg.result.TotalFiles
|
||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||
m.clampEntrySelection()
|
||||
m.clampLargeSelection()
|
||||
m.cache[m.path] = cacheSnapshot(m)
|
||||
@@ -470,6 +505,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
_ = storeOverviewSize(path, size)
|
||||
}(m.path, m.totalSize)
|
||||
}
|
||||
|
||||
if msg.stale {
|
||||
m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path))
|
||||
m.scanning = true
|
||||
if m.totalFiles > 0 {
|
||||
m.lastTotalFiles = m.totalFiles
|
||||
}
|
||||
atomic.StoreInt64(m.filesScanned, 0)
|
||||
atomic.StoreInt64(m.dirsScanned, 0)
|
||||
atomic.StoreInt64(m.bytesScanned, 0)
|
||||
if m.currentPath != nil {
|
||||
m.currentPath.Store("")
|
||||
}
|
||||
return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd())
|
||||
}
|
||||
|
||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||
return m, nil
|
||||
case overviewSizeMsg:
|
||||
delete(m.overviewScanningSet, msg.Path)
|
||||
|
||||
@@ -23,6 +23,21 @@ import (
|
||||
|
||||
var scanGroup singleflight.Group
|
||||
|
||||
// trySend attempts to send an item to a channel with a timeout.
|
||||
// Returns true if the item was sent, false if the timeout was reached.
|
||||
func trySend[T any](ch chan<- T, item T, timeout time.Duration) bool {
|
||||
timer := time.NewTimer(timeout)
|
||||
select {
|
||||
case ch <- item:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) {
|
||||
children, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
@@ -119,42 +134,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
size := getActualFileSize(fullPath, info)
|
||||
atomic.AddInt64(&total, size)
|
||||
|
||||
// Reuse timer to reduce GC pressure
|
||||
timer := time.NewTimer(0)
|
||||
// Ensure timer is drained immediately since we start with 0
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
trySend(entryChan, dirEntry{
|
||||
Name: child.Name() + " →",
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: isDir,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}:
|
||||
default:
|
||||
// If channel is full, use timer to wait with timeout
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: child.Name() + " →",
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: isDir,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
// Skip if channel is blocked
|
||||
}
|
||||
}
|
||||
}, 100*time.Millisecond)
|
||||
continue
|
||||
|
||||
}
|
||||
@@ -188,20 +174,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
trySend(entryChan, dirEntry{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{},
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}, 100*time.Millisecond)
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
}
|
||||
@@ -225,20 +204,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
trySend(entryChan, dirEntry{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{},
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}, 100*time.Millisecond)
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
}
|
||||
@@ -253,20 +225,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
trySend(entryChan, dirEntry{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{},
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}, 100*time.Millisecond)
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
}
|
||||
@@ -281,35 +246,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(filesScanned, 1)
|
||||
atomic.AddInt64(bytesScanned, size)
|
||||
|
||||
// Single-use timer for main loop (less pressure than tight loop above)
|
||||
// But let's be consistent and optimized
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
trySend(entryChan, dirEntry{
|
||||
Name: child.Name(),
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: false,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}, 100*time.Millisecond)
|
||||
|
||||
// Track large files only.
|
||||
if !shouldSkipFileForLargeTracking(fullPath) {
|
||||
minSize := atomic.LoadInt64(&largeFileMinSize)
|
||||
if size >= minSize {
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
select {
|
||||
case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,14 +351,20 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subDir := filepath.Join(dirPath, entry.Name())
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
walk(p)
|
||||
}(subDir)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
wg.Add(1)
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
walk(p)
|
||||
}(subDir)
|
||||
default:
|
||||
// Fallback to synchronous traversal to avoid semaphore deadlock under high fan-out.
|
||||
walk(subDir)
|
||||
}
|
||||
} else {
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
@@ -519,15 +474,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar
|
||||
maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
// Reuse timer for large file sends
|
||||
timer := time.NewTimer(0)
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
fullPath := filepath.Join(root, child.Name())
|
||||
|
||||
@@ -593,14 +539,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar
|
||||
if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil {
|
||||
minSize := atomic.LoadInt64(largeFileMinSize)
|
||||
if size >= minSize {
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
select {
|
||||
case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ const NetworkHistorySize = 120 // Increased history size for wider graph
|
||||
|
||||
type ProxyStatus struct {
|
||||
Enabled bool
|
||||
Type string // HTTP, SOCKS, System
|
||||
Type string // HTTP, HTTPS, SOCKS, PAC, WPAD, TUN
|
||||
Host string
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -114,23 +116,8 @@ func isNoiseInterface(name string) bool {
|
||||
}
|
||||
|
||||
func collectProxy() ProxyStatus {
|
||||
// Check environment variables first.
|
||||
for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} {
|
||||
if val := os.Getenv(env); val != "" {
|
||||
proxyType := "HTTP"
|
||||
if strings.HasPrefix(val, "socks") {
|
||||
proxyType = "SOCKS"
|
||||
}
|
||||
// Extract host.
|
||||
host := val
|
||||
if strings.Contains(host, "://") {
|
||||
host = strings.SplitN(host, "://", 2)[1]
|
||||
}
|
||||
if idx := strings.Index(host, "@"); idx >= 0 {
|
||||
host = host[idx+1:]
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: proxyType, Host: host}
|
||||
}
|
||||
if proxy := collectProxyFromEnv(os.Getenv); proxy.Enabled {
|
||||
return proxy
|
||||
}
|
||||
|
||||
// macOS: check system proxy via scutil.
|
||||
@@ -139,14 +126,166 @@ func collectProxy() ProxyStatus {
|
||||
defer cancel()
|
||||
out, err := runCmd(ctx, "scutil", "--proxy")
|
||||
if err == nil {
|
||||
if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") {
|
||||
return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"}
|
||||
}
|
||||
if strings.Contains(out, "SOCKSEnable : 1") {
|
||||
return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"}
|
||||
if proxy := collectProxyFromScutilOutput(out); proxy.Enabled {
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
|
||||
if proxy := collectProxyFromTunInterfaces(); proxy.Enabled {
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
|
||||
return ProxyStatus{Enabled: false}
|
||||
}
|
||||
|
||||
func collectProxyFromEnv(getenv func(string) string) ProxyStatus {
|
||||
// Include ALL_PROXY for users running proxy tools that only export a single variable.
|
||||
envKeys := []string{
|
||||
"https_proxy", "HTTPS_PROXY",
|
||||
"http_proxy", "HTTP_PROXY",
|
||||
"all_proxy", "ALL_PROXY",
|
||||
}
|
||||
for _, key := range envKeys {
|
||||
val := strings.TrimSpace(getenv(key))
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
proxyType := "HTTP"
|
||||
lower := strings.ToLower(val)
|
||||
if strings.HasPrefix(lower, "socks") {
|
||||
proxyType = "SOCKS"
|
||||
}
|
||||
|
||||
host := parseProxyHost(val)
|
||||
if host == "" {
|
||||
host = val
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: proxyType, Host: host}
|
||||
}
|
||||
|
||||
return ProxyStatus{Enabled: false}
|
||||
}
|
||||
|
||||
func collectProxyFromScutilOutput(out string) ProxyStatus {
|
||||
if out == "" {
|
||||
return ProxyStatus{Enabled: false}
|
||||
}
|
||||
|
||||
if scutilProxyEnabled(out, "SOCKSEnable") {
|
||||
host := joinHostPort(scutilProxyValue(out, "SOCKSProxy"), scutilProxyValue(out, "SOCKSPort"))
|
||||
if host == "" {
|
||||
host = "System Proxy"
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: "SOCKS", Host: host}
|
||||
}
|
||||
|
||||
if scutilProxyEnabled(out, "HTTPSEnable") {
|
||||
host := joinHostPort(scutilProxyValue(out, "HTTPSProxy"), scutilProxyValue(out, "HTTPSPort"))
|
||||
if host == "" {
|
||||
host = "System Proxy"
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: "HTTPS", Host: host}
|
||||
}
|
||||
|
||||
if scutilProxyEnabled(out, "HTTPEnable") {
|
||||
host := joinHostPort(scutilProxyValue(out, "HTTPProxy"), scutilProxyValue(out, "HTTPPort"))
|
||||
if host == "" {
|
||||
host = "System Proxy"
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: "HTTP", Host: host}
|
||||
}
|
||||
|
||||
if scutilProxyEnabled(out, "ProxyAutoConfigEnable") {
|
||||
pacURL := scutilProxyValue(out, "ProxyAutoConfigURLString")
|
||||
host := parseProxyHost(pacURL)
|
||||
if host == "" {
|
||||
host = "PAC"
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: "PAC", Host: host}
|
||||
}
|
||||
|
||||
if scutilProxyEnabled(out, "ProxyAutoDiscoveryEnable") {
|
||||
return ProxyStatus{Enabled: true, Type: "WPAD", Host: "Auto Discovery"}
|
||||
}
|
||||
|
||||
return ProxyStatus{Enabled: false}
|
||||
}
|
||||
|
||||
func collectProxyFromTunInterfaces() ProxyStatus {
|
||||
stats, err := net.IOCounters(true)
|
||||
if err != nil {
|
||||
return ProxyStatus{Enabled: false}
|
||||
}
|
||||
|
||||
var activeTun []string
|
||||
for _, s := range stats {
|
||||
lower := strings.ToLower(s.Name)
|
||||
if strings.HasPrefix(lower, "utun") || strings.HasPrefix(lower, "tun") {
|
||||
if s.BytesRecv+s.BytesSent > 0 {
|
||||
activeTun = append(activeTun, s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(activeTun) == 0 {
|
||||
return ProxyStatus{Enabled: false}
|
||||
}
|
||||
sort.Strings(activeTun)
|
||||
host := activeTun[0]
|
||||
if len(activeTun) > 1 {
|
||||
host = activeTun[0] + "+"
|
||||
}
|
||||
return ProxyStatus{Enabled: true, Type: "TUN", Host: host}
|
||||
}
|
||||
|
||||
func scutilProxyEnabled(out, key string) bool {
|
||||
return scutilProxyValue(out, key) == "1"
|
||||
}
|
||||
|
||||
func scutilProxyValue(out, key string) string {
|
||||
prefix := key + " :"
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseProxyHost(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
target := raw
|
||||
if !strings.Contains(target, "://") {
|
||||
target = "http://" + target
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := parsed.Host
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(host, "@")
|
||||
}
|
||||
|
||||
func joinHostPort(host, port string) string {
|
||||
host = strings.TrimSpace(host)
|
||||
port = strings.TrimSpace(port)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if port == "" {
|
||||
return host
|
||||
}
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return host
|
||||
}
|
||||
return host + ":" + port
|
||||
}
|
||||
|
||||
60
cmd/status/metrics_network_test.go
Normal file
60
cmd/status/metrics_network_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCollectProxyFromEnvSupportsAllProxy(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"ALL_PROXY": "socks5://127.0.0.1:7890",
|
||||
}
|
||||
getenv := func(key string) string {
|
||||
return env[key]
|
||||
}
|
||||
|
||||
got := collectProxyFromEnv(getenv)
|
||||
if !got.Enabled {
|
||||
t.Fatalf("expected proxy enabled")
|
||||
}
|
||||
if got.Type != "SOCKS" {
|
||||
t.Fatalf("expected SOCKS type, got %s", got.Type)
|
||||
}
|
||||
if got.Host != "127.0.0.1:7890" {
|
||||
t.Fatalf("unexpected host: %s", got.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectProxyFromScutilOutputPAC(t *testing.T) {
|
||||
out := `
|
||||
<dictionary> {
|
||||
ProxyAutoConfigEnable : 1
|
||||
ProxyAutoConfigURLString : http://127.0.0.1:6152/proxy.pac
|
||||
}`
|
||||
got := collectProxyFromScutilOutput(out)
|
||||
if !got.Enabled {
|
||||
t.Fatalf("expected proxy enabled")
|
||||
}
|
||||
if got.Type != "PAC" {
|
||||
t.Fatalf("expected PAC type, got %s", got.Type)
|
||||
}
|
||||
if got.Host != "127.0.0.1:6152" {
|
||||
t.Fatalf("unexpected host: %s", got.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectProxyFromScutilOutputHTTPHostPort(t *testing.T) {
|
||||
out := `
|
||||
<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPProxy : 127.0.0.1
|
||||
HTTPPort : 7890
|
||||
}`
|
||||
got := collectProxyFromScutilOutput(out)
|
||||
if !got.Enabled {
|
||||
t.Fatalf("expected proxy enabled")
|
||||
}
|
||||
if got.Type != "HTTP" {
|
||||
t.Fatalf("expected HTTP type, got %s", got.Type)
|
||||
}
|
||||
if got.Host != "127.0.0.1:7890" {
|
||||
t.Fatalf("unexpected host: %s", got.Host)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user