From cd10111ab45d0740ba35305bc8c1bd08c10b44d2 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 19 May 2026 23:20:56 +0800 Subject: [PATCH 1/2] fix(brew): track exact brew cache path so cask download bytes update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CacheTracker matched files by substring of the cask name, but brew names cached downloads after the URL's basename, not the cask. Casks like google-chrome (cached as `…--googlechrome.dmg`, no hyphen) and tencent-meeting (`…--TencentMeeting_….dmg`) never matched, so the head showed `0B/` for the entire download while StickyProgress's carried-over EMA made the speed/ETA look alive. Resolve the exact path via `brew --cache --cask ` and stat both `` and `.incomplete` (brew's documented temporary-file convention — see Homebrew's abstract_file_download_strategy.rb). Report the larger size, which covers both downloading and completed states. --- internal/brew/cache.go | 52 ++++++++++++++++--------------------- internal/brew/cache_test.go | 26 +++++++++---------- 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/internal/brew/cache.go b/internal/brew/cache.go index 525d314..a5e4e24 100644 --- a/internal/brew/cache.go +++ b/internal/brew/cache.go @@ -3,35 +3,34 @@ package brew import ( "context" "os" - "path/filepath" "strings" "time" ) -// CacheTracker polls the brew downloads directory for the partial file -// matching a cask name and reports its current size. +// CacheTracker polls the exact brew download path for a cask and reports the +// partial file's current size. During download brew writes to +// `.incomplete`, then renames to `` on success — we +// stat both and report the larger. type CacheTracker struct { - cacheDir string - match string - interval time.Duration + finalPath string + interval time.Duration } -// NewCacheTracker builds a tracker for the given cask. cacheDir is resolved -// via `brew --cache --cask ` (parent dir). +// NewCacheTracker builds a tracker for the given cask. The exact cache path +// is resolved via `brew --cache --cask `. func NewCacheTracker(caskName string) (*CacheTracker, error) { - dir, err := resolveBrewCacheDir(caskName) + path, err := resolveBrewCachePath(caskName) if err != nil { return nil, err } return &CacheTracker{ - cacheDir: dir, - match: caskName, - interval: 500 * time.Millisecond, + finalPath: path, + interval: 500 * time.Millisecond, }, nil } -// Run polls every interval and invokes onProgress with the current matched -// file size. Stops when ctx is done. Always emits at least one final value +// Run polls every interval and invokes onProgress with the current file +// size. Stops when ctx is done. Always emits at least one final value // before returning. func (t *CacheTracker) Run(ctx context.Context, onProgress func(bytes int64)) { ticker := time.NewTicker(t.interval) @@ -51,18 +50,9 @@ func (t *CacheTracker) Run(ctx context.Context, onProgress func(bytes int64)) { } func (t *CacheTracker) currentSize() int64 { - entries, err := os.ReadDir(t.cacheDir) - if err != nil { - return 0 - } var largest int64 - needle := strings.ToLower(t.match) - for _, e := range entries { - name := strings.ToLower(e.Name()) - if !strings.Contains(name, needle) { - continue - } - info, err := e.Info() + for _, p := range []string{t.finalPath, t.finalPath + ".incomplete"} { + info, err := os.Stat(p) if err != nil { continue } @@ -73,10 +63,12 @@ func (t *CacheTracker) currentSize() int64 { return largest } -// resolveBrewCacheDir asks brew where it stores downloads for the given cask -// and returns the containing directory. (`brew --cache --cask X` returns the -// expected full path; we use its parent so we can glob multiple candidates.) -func resolveBrewCacheDir(caskName string) (string, error) { +// resolveBrewCachePath returns the exact path brew uses for the cask's +// download. Matching the previous substring-based approach against the cask +// name is unreliable: brew names the cached file after the URL's basename +// (e.g. `google-chrome` → `…--googlechrome.dmg`), so the cask name often +// doesn't appear in it. +func resolveBrewCachePath(caskName string) (string, error) { out, err := currentRunner().Output("--cache", "--cask", caskName) if err != nil { return "", err @@ -85,5 +77,5 @@ func resolveBrewCacheDir(caskName string) (string, error) { if path == "" { return "", os.ErrNotExist } - return filepath.Dir(path), nil + return path, nil } diff --git a/internal/brew/cache_test.go b/internal/brew/cache_test.go index 974b4ae..2830fc3 100644 --- a/internal/brew/cache_test.go +++ b/internal/brew/cache_test.go @@ -19,9 +19,8 @@ func TestCacheTrackerReportsFileSize(t *testing.T) { require.NoError(t, os.WriteFile(caskFile, make([]byte, 1024), 0o644)) tracker := &CacheTracker{ - cacheDir: dir, - match: "Docker", - interval: 10 * time.Millisecond, + finalPath: caskFile, + interval: 10 * time.Millisecond, } var observed atomic.Int64 @@ -37,23 +36,23 @@ func TestCacheTrackerReportsFileSize(t *testing.T) { assert.EqualValues(t, 1024, observed.Load()) } -func TestCacheTrackerPicksLargestMatchingFile(t *testing.T) { +func TestCacheTrackerPrefersIncompleteWhileDownloading(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "old--Docker.dmg"), make([]byte, 100), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "new--Docker.dmg.incomplete"), make([]byte, 5000), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated--Rectangle.dmg"), make([]byte, 9999), 0o644)) + final := filepath.Join(dir, "abc123--Docker.dmg") + // While downloading brew writes to .incomplete; only after the + // download completes does it rename to . We should report the + // larger of the two so the bar advances during the download. + require.NoError(t, os.WriteFile(final+".incomplete", make([]byte, 5000), 0o644)) tracker := &CacheTracker{ - cacheDir: dir, - match: "Docker", - interval: 10 * time.Millisecond, + finalPath: final, + interval: 10 * time.Millisecond, } var observed atomic.Int64 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() tracker.Run(ctx, func(bytes int64) { observed.Store(bytes) }) - // Picks the largest matching file (the .incomplete download). assert.EqualValues(t, 5000, observed.Load()) } @@ -62,9 +61,8 @@ func TestCacheTrackerNoMatchReportsZero(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated--Other.dmg"), make([]byte, 100), 0o644)) tracker := &CacheTracker{ - cacheDir: dir, - match: "Docker", - interval: 10 * time.Millisecond, + finalPath: filepath.Join(dir, "abc123--Docker.dmg"), + interval: 10 * time.Millisecond, } var observed atomic.Int64 From 542649cf6a2d12131de9982ad02985c8940e4ff4 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 19 May 2026 23:24:56 +0800 Subject: [PATCH 2/2] test(brew): pin CacheTracker max() behavior when both files exist --- internal/brew/cache_test.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/brew/cache_test.go b/internal/brew/cache_test.go index 2830fc3..5d19cd5 100644 --- a/internal/brew/cache_test.go +++ b/internal/brew/cache_test.go @@ -36,12 +36,33 @@ func TestCacheTrackerReportsFileSize(t *testing.T) { assert.EqualValues(t, 1024, observed.Load()) } -func TestCacheTrackerPrefersIncompleteWhileDownloading(t *testing.T) { +func TestCacheTrackerReadsIncompleteWhileDownloading(t *testing.T) { dir := t.TempDir() final := filepath.Join(dir, "abc123--Docker.dmg") // While downloading brew writes to .incomplete; only after the - // download completes does it rename to . We should report the - // larger of the two so the bar advances during the download. + // download completes does it rename to . The tracker reads the + // partial so the bar advances during the download. + require.NoError(t, os.WriteFile(final+".incomplete", make([]byte, 5000), 0o644)) + + tracker := &CacheTracker{ + finalPath: final, + interval: 10 * time.Millisecond, + } + + var observed atomic.Int64 + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + tracker.Run(ctx, func(bytes int64) { observed.Store(bytes) }) + assert.EqualValues(t, 5000, observed.Load()) +} + +func TestCacheTrackerReportsLargerWhenBothExist(t *testing.T) { + // Edge case: a stale final file from a prior install plus an in-flight + // .incomplete. The tracker must report the larger so the bar reflects + // the actual download progress, not the stale leftover. + dir := t.TempDir() + final := filepath.Join(dir, "abc123--Docker.dmg") + require.NoError(t, os.WriteFile(final, make([]byte, 100), 0o644)) require.NoError(t, os.WriteFile(final+".incomplete", make([]byte, 5000), 0o644)) tracker := &CacheTracker{