Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 22 additions & 30 deletions internal/brew/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<finalPath>.incomplete`, then renames to `<finalPath>` 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 <name>` (parent dir).
// NewCacheTracker builds a tracker for the given cask. The exact cache path
// is resolved via `brew --cache --cask <name>`.
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)
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -85,5 +77,5 @@ func resolveBrewCacheDir(caskName string) (string, error) {
if path == "" {
return "", os.ErrNotExist
}
return filepath.Dir(path), nil
return path, nil
}
47 changes: 33 additions & 14 deletions internal/brew/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,23 +36,44 @@ func TestCacheTrackerReportsFileSize(t *testing.T) {
assert.EqualValues(t, 1024, observed.Load())
}

func TestCacheTrackerPicksLargestMatchingFile(t *testing.T) {
func TestCacheTrackerReadsIncompleteWhileDownloading(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 <final>.incomplete; only after the
// download completes does it rename to <final>. 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{
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) })
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{
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())
}

Expand All @@ -62,9 +82,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
Expand Down
Loading