From 6e1dfd20e71cfd59b81b0cae7290638f1632bf64 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 19 Mar 2026 13:33:22 +0800 Subject: [PATCH] fix(status): correct external disk capacity totals --- cmd/status/metrics.go | 4 +- cmd/status/metrics_disk.go | 102 ++++++++++++++++++++++++-------- cmd/status/metrics_disk_test.go | 90 ++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 27 deletions(-) diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index d24e3f1..0224b66 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -347,7 +347,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { }, mergeErr } -func runCmd(ctx context.Context, name string, args ...string) (string, error) { +var runCmd = func(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.Output() if err != nil { @@ -356,7 +356,7 @@ func runCmd(ctx context.Context, name string, args ...string) (string, error) { return string(output), nil } -func commandExists(name string) bool { +var commandExists = func(name string) bool { if name == "" { return false } diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index d4cf39f..39a0a9d 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -67,26 +67,30 @@ func collectDisks() ([]DiskStatus, error) { if err != nil || usage.Total == 0 { continue } + total := usage.Total + if runtime.GOOS == "darwin" { + total = correctDiskTotalBytes(part.Mountpoint, total) + } // Skip <1GB volumes. - if usage.Total < 1<<30 { + if total < 1<<30 { continue } // Use size-based dedupe key for shared pools. - volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) + volKey := fmt.Sprintf("%s:%d", part.Fstype, total) if seenVolume[volKey] { continue } used := usage.Used usedPercent := usage.UsedPercent if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" { - used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, usage.Total, usage.Used) + used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used) } disks = append(disks, DiskStatus{ Mount: part.Mountpoint, Device: part.Device, Used: used, - Total: usage.Total, + Total: total, UsedPercent: usedPercent, Fstype: part.Fstype, }) @@ -228,6 +232,39 @@ func isExternalDisk(device string) (bool, error) { return external, nil } +// correctDiskTotalBytes uses diskutil's plist output when macOS reports a +// meaningfully different disk size than gopsutil. This fixes external APFS +// volumes that can show doubled capacities through statfs/gopsutil. +func correctDiskTotalBytes(mountpoint string, rawTotal uint64) uint64 { + if rawTotal == 0 || !commandExists("diskutil") { + return rawTotal + } + + diskutilTotal, err := getDiskutilTotalBytes(mountpoint) + if err != nil || diskutilTotal == 0 { + return rawTotal + } + + if uint64AbsDiff(rawTotal, diskutilTotal) > 1<<30 { + return diskutilTotal + } + + return rawTotal +} + +func getDiskutilTotalBytes(mountpoint string) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint) + if err != nil { + return 0, err + } + + // Prefer TotalSize, but keep older/plainer keys as fallbacks. + return extractPlistUint(out, "TotalSize", "DiskSize", "Size") +} + // correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an // APFS volume, accounting for purgeable caches and APFS local snapshots that // statfs incorrectly counts as "used". Uses a three-tier fallback: @@ -274,27 +311,7 @@ func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) { return 0, err } - const key = "APFSContainerFree" - _, rest, found := strings.Cut(out, key) - if !found { - return 0, fmt.Errorf("APFSContainerFree not found") - } - - _, rest, found = strings.Cut(rest, "") - if !found { - return 0, fmt.Errorf("APFSContainerFree value not found") - } - - value, _, found := strings.Cut(rest, "") - if !found { - return 0, fmt.Errorf("APFSContainerFree end tag not found") - } - - val, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) - if err != nil { - return 0, fmt.Errorf("failed to parse APFSContainerFree: %v", err) - } - return val, nil + return extractPlistUint(out, "APFSContainerFree") } // getFinderStartupDiskFreeBytes queries Finder via osascript for the startup @@ -336,6 +353,41 @@ func getFinderStartupDiskFreeBytes() (free, total uint64, err error) { return finderDiskFree, finderDiskTotal, nil } +func extractPlistUint(plist string, keys ...string) (uint64, error) { + for _, key := range keys { + marker := "" + key + "" + _, rest, found := strings.Cut(plist, marker) + if !found { + continue + } + + _, rest, found = strings.Cut(rest, "") + if !found { + continue + } + + value, _, found := strings.Cut(rest, "") + if !found { + continue + } + + parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse %s: %v", key, err) + } + return parsed, nil + } + + return 0, fmt.Errorf("%s not found", strings.Join(keys, "/")) +} + +func uint64AbsDiff(a, b uint64) uint64 { + if a > b { + return a - b + } + return b - a +} + func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { counters, err := disk.IOCounters() if err != nil || len(counters) == 0 { diff --git a/cmd/status/metrics_disk_test.go b/cmd/status/metrics_disk_test.go index 32ed721..24fed33 100644 --- a/cmd/status/metrics_disk_test.go +++ b/cmd/status/metrics_disk_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "errors" "testing" "github.com/shirou/gopsutil/v4/disk" @@ -58,3 +60,91 @@ func TestShouldSkipDiskPartition(t *testing.T) { }) } } + +func TestExtractPlistUint(t *testing.T) { + t.Run("prefers first matching key", func(t *testing.T) { + raw := ` +TotalSize1099511627776 +DiskSize2199023255552 +` + + got, err := extractPlistUint(raw, "TotalSize", "DiskSize") + if err != nil { + t.Fatalf("extractPlistUint() error = %v", err) + } + if got != 1099511627776 { + t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("falls back to later keys", func(t *testing.T) { + raw := `DiskSize1099511627776` + + got, err := extractPlistUint(raw, "TotalSize", "DiskSize", "Size") + if err != nil { + t.Fatalf("extractPlistUint() error = %v", err) + } + if got != 1099511627776 { + t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("returns error for malformed integer", func(t *testing.T) { + raw := `TotalSizeoops` + + if _, err := extractPlistUint(raw, "TotalSize"); err == nil { + t.Fatalf("extractPlistUint() expected parse error") + } + }) +} + +func TestCorrectDiskTotalBytes(t *testing.T) { + origRunCmd := runCmd + origCommandExists := commandExists + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + }) + + commandExists = func(name string) bool { + return name == "diskutil" + } + + t.Run("uses diskutil total when meaningfully different", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + if name != "diskutil" { + return "", errors.New("unexpected command") + } + return `TotalSize1099511627776`, nil + } + + got := correctDiskTotalBytes("/Volumes/Backup", 2199023255552) + if got != 1099511627776 { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("keeps raw total for small differences", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return `TotalSize1000500000000`, nil + } + + const rawTotal = 1000000000000 + got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal) + if got != rawTotal { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal)) + } + }) + + t.Run("keeps raw total when diskutil fails", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return "", errors.New("diskutil failed") + } + + const rawTotal = 1099511627776 + got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal) + if got != rawTotal { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal)) + } + }) +}