diff --git a/app-modern/ui/gio/shell.go b/app-modern/ui/gio/shell.go index 3536296..ba203e4 100644 --- a/app-modern/ui/gio/shell.go +++ b/app-modern/ui/gio/shell.go @@ -103,14 +103,19 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) { shell.model.SetItems(items) nextRows := make(map[string]*rowControls, len(items)) + nextIcons := make(map[string]paint.ImageOp, len(items)) for _, item := range items { controls := shell.rows[item.ID] if controls == nil { controls = new(rowControls) } nextRows[item.ID] = controls + if icon, exists := shell.icons[item.ID]; exists { + nextIcons[item.ID] = icon + } } shell.rows = nextRows + shell.icons = nextIcons nextCategories := make(map[string]*widget.Clickable) for _, category := range append([]string{""}, shell.model.Categories()...) { diff --git a/app-modern/ui/gio/shell_test.go b/app-modern/ui/gio/shell_test.go index c137ff9..30e6161 100644 --- a/app-modern/ui/gio/shell_test.go +++ b/app-modern/ui/gio/shell_test.go @@ -61,6 +61,8 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) { } shell := NewAppShell("Modern", items...) original := shell.rows["app-two"] + shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16))) + shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24))) shell.model.SetCategory("图像") shell.Layout(testContext(image.Pt(1080, 720)), NewTheme()) @@ -77,6 +79,16 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) { if _, exists := shell.rows["app-one"]; exists { t.Fatal("removed app retained row controls") } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("removed app retained prepared icon") + } + icon, exists := shell.icons["app-two"] + if !exists { + t.Fatal("retained app lost its prepared icon") + } + if icon.Size() != image.Pt(24, 24) { + t.Fatalf("retained app icon size = %v", icon.Size()) + } } func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) { diff --git a/app-win7/ui/gio/shell.go b/app-win7/ui/gio/shell.go index cb66763..c1c9324 100644 --- a/app-win7/ui/gio/shell.go +++ b/app-win7/ui/gio/shell.go @@ -103,14 +103,19 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) { shell.model.SetItems(items) nextRows := make(map[string]*rowControls, len(items)) + nextIcons := make(map[string]paint.ImageOp, len(items)) for _, item := range items { controls := shell.rows[item.ID] if controls == nil { controls = new(rowControls) } nextRows[item.ID] = controls + if icon, exists := shell.icons[item.ID]; exists { + nextIcons[item.ID] = icon + } } shell.rows = nextRows + shell.icons = nextIcons nextCategories := make(map[string]*widget.Clickable) for _, category := range append([]string{""}, shell.model.Categories()...) { diff --git a/app-win7/ui/gio/shell_test.go b/app-win7/ui/gio/shell_test.go index 5be62c2..72c4b81 100644 --- a/app-win7/ui/gio/shell_test.go +++ b/app-win7/ui/gio/shell_test.go @@ -59,6 +59,8 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) { } shell := NewAppShell("Legacy", items...) original := shell.rows["app-two"] + shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16))) + shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24))) shell.model.SetCategory("图像") shell.Layout(testContext(image.Pt(1024, 680)), NewTheme()) @@ -75,6 +77,16 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) { if _, exists := shell.rows["app-one"]; exists { t.Fatal("removed app retained row controls") } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("removed app retained prepared icon") + } + icon, exists := shell.icons["app-two"] + if !exists { + t.Fatal("retained app lost its prepared icon") + } + if icon.Size() != image.Pt(24, 24) { + t.Fatalf("retained app icon size = %v", icon.Size()) + } } func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) { diff --git a/core/catalog/icon_cache.go b/core/catalog/icon_cache.go index 3c2c459..44b6f68 100644 --- a/core/catalog/icon_cache.go +++ b/core/catalog/icon_cache.go @@ -11,6 +11,7 @@ import ( _ "image/gif" _ "image/jpeg" _ "image/png" + "io" "os" "path/filepath" "strconv" @@ -19,8 +20,11 @@ import ( ) const ( - DefaultMaxIconBytes int64 = 2 << 20 - DefaultMaxIconDimension = 2048 + DefaultMaxIconBytes int64 = 2 << 20 + DefaultMaxIconDimension = 2048 + DefaultIconMemoryBytes int64 = 32 << 20 + DefaultIconMemoryEntries = 256 + UnknownIconContentLength int64 = -1 ) var ( @@ -29,6 +33,7 @@ var ( ErrIconHashMismatch = errors.New("icon SHA-256 does not match reference") ErrIconTooLarge = errors.New("icon exceeds resource limits") ErrIconImageInvalid = errors.New("icon image is invalid") + ErrIconResponseInvalid = errors.New("icon fetch response is invalid") ErrIconCacheUnsafe = errors.New("icon cache layout is unsafe") ErrNoValidIcon = errors.New("no valid icon available") ) @@ -39,18 +44,26 @@ type IconRequest struct { DPI int } -// IconFetcher obtains icon bytes outside Gio Layout. +// IconFetchResponse streams one icon while preserving its optional declared size. +// Body ownership transfers to IconCache and reads must stop when the FetchIcon +// context is canceled. +type IconFetchResponse struct { + Body io.ReadCloser + ContentLength int64 +} + +// IconFetcher opens an icon response outside Gio Layout. type IconFetcher interface { - FetchIcon(context.Context, IconRequest) ([]byte, error) + FetchIcon(context.Context, IconRequest) (IconFetchResponse, error) } // IconFetchFunc adapts a function to IconFetcher. -type IconFetchFunc func(context.Context, IconRequest) ([]byte, error) +type IconFetchFunc func(context.Context, IconRequest) (IconFetchResponse, error) func (function IconFetchFunc) FetchIcon( ctx context.Context, request IconRequest, -) ([]byte, error) { +) (IconFetchResponse, error) { return function(ctx, request) } @@ -96,7 +109,14 @@ type IconCache struct { maxBytes int64 maxDimension int mu sync.Mutex - memory map[string][]byte + memory *iconMemoryCache + inflight map[string]*iconFlight +} + +type iconFlight struct { + done chan struct{} + result IconResult + err error } // NewIconCache creates a cache with safe default resource limits. @@ -106,35 +126,70 @@ func NewIconCache(root string, fetcher IconFetcher) *IconCache { fetcher: fetcher, maxBytes: DefaultMaxIconBytes, maxDimension: DefaultMaxIconDimension, - memory: make(map[string][]byte), + memory: newIconMemoryCache( + DefaultIconMemoryBytes, + DefaultIconMemoryEntries, + ), + inflight: make(map[string]*iconFlight), } } // Load resolves memory, verified disk, then verified remote bytes. func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResult, error) { - cache.mu.Lock() - defer cache.mu.Unlock() - digest, key, err := cacheKey(request) if err != nil { return IconResult{}, err } - if document, exists := cache.memory[key]; exists { + + cache.mu.Lock() + if document, exists := cache.memory.get(key); exists { + cache.mu.Unlock() return IconResult{ - Bytes: append([]byte(nil), document...), + Bytes: document, Source: IconSourceMemory, }, nil } + if flight, exists := cache.inflight[key]; exists { + cache.mu.Unlock() + select { + case <-flight.done: + return cloneIconResult(flight.result), flight.err + case <-ctx.Done(): + return IconResult{}, ctx.Err() + } + } + flight := &iconFlight{done: make(chan struct{})} + cache.inflight[key] = flight + cache.mu.Unlock() + result, loadErr := cache.loadUncached(ctx, request, digest) + + cache.mu.Lock() + if loadErr == nil { + cache.memory.put(key, result.Bytes) + } + flight.result = cloneIconResult(result) + flight.err = loadErr + delete(cache.inflight, key) + close(flight.done) + cache.mu.Unlock() + + return cloneIconResult(result), loadErr +} + +func (cache *IconCache) loadUncached( + ctx context.Context, + request IconRequest, + digest string, +) (IconResult, error) { filePath, pathErr := cache.filePath(digest, request.DPI) if pathErr != nil { return IconResult{}, pathErr } document, diskErr := cache.loadDisk(filePath, digest) if diskErr == nil { - cache.memory[key] = append([]byte(nil), document...) return IconResult{ - Bytes: append([]byte(nil), document...), + Bytes: document, Source: IconSourceDisk, }, nil } @@ -157,7 +212,11 @@ func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResu Fetch: errors.New("icon fetcher is not configured"), } } - document, fetchErr := cache.fetcher.FetchIcon(ctx, request) + response, fetchErr := cache.fetcher.FetchIcon(ctx, request) + if fetchErr != nil { + return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr} + } + document, fetchErr = cache.readFetchedIcon(ctx, response) if fetchErr != nil { return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr} } @@ -166,14 +225,73 @@ func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResu } storeErr := cache.storeDisk(filePath, document) - cache.memory[key] = append([]byte(nil), document...) return IconResult{ - Bytes: append([]byte(nil), document...), + Bytes: document, Source: IconSourceRemote, Warning: storeErr, }, nil } +func (cache *IconCache) readFetchedIcon( + ctx context.Context, + response IconFetchResponse, +) (document []byte, resultErr error) { + if response.Body == nil { + return nil, fmt.Errorf("%w: nil body", ErrIconResponseInvalid) + } + defer func() { + if closeErr := response.Body.Close(); resultErr == nil && closeErr != nil { + resultErr = fmt.Errorf("close icon response: %w", closeErr) + } + }() + + if response.ContentLength < UnknownIconContentLength { + return nil, fmt.Errorf( + "%w: content length %d", + ErrIconResponseInvalid, + response.ContentLength, + ) + } + maxBytes := cache.iconByteLimit() + if response.ContentLength > maxBytes { + return nil, fmt.Errorf( + "%w: declared bytes=%d limit=%d", + ErrIconTooLarge, + response.ContentLength, + maxBytes, + ) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + document, readErr := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) + if readErr != nil { + return nil, fmt.Errorf("read icon response: %w", readErr) + } + if int64(len(document)) > maxBytes { + return nil, fmt.Errorf( + "%w: bytes=%d limit=%d", + ErrIconTooLarge, + len(document), + maxBytes, + ) + } + return document, nil +} + +func (cache *IconCache) iconByteLimit() int64 { + if cache.maxBytes <= 0 { + return DefaultMaxIconBytes + } + return cache.maxBytes +} + +func cloneIconResult(result IconResult) IconResult { + result.Bytes = append([]byte(nil), result.Bytes...) + return result +} + // DecodeIcon decodes already verified bytes outside Layout for ApplyIcon. func DecodeIcon(document []byte) (image.Image, error) { decoded, _, err := image.Decode(bytes.NewReader(document)) @@ -184,10 +302,7 @@ func DecodeIcon(document []byte) (image.Image, error) { } func (cache *IconCache) validate(document []byte, digest string) error { - maxBytes := cache.maxBytes - if maxBytes <= 0 { - maxBytes = DefaultMaxIconBytes - } + maxBytes := cache.iconByteLimit() if int64(len(document)) > maxBytes { return fmt.Errorf( "%w: bytes=%d limit=%d", diff --git a/core/catalog/icon_cache_concurrency_test.go b/core/catalog/icon_cache_concurrency_test.go new file mode 100644 index 0000000..0b87695 --- /dev/null +++ b/core/catalog/icon_cache_concurrency_test.go @@ -0,0 +1,411 @@ +package catalog + +import ( + "bytes" + "context" + "errors" + "os" + "sync/atomic" + "testing" + "time" +) + +type iconLoadOutcome struct { + result IconResult + err error +} + +func TestIconCacheMemoryHitDoesNotWaitForDifferentKey(t *testing.T) { + fastDocument := testPNG(t, 15, 15) + slowDocument := testPNG(t, 16, 16) + fastRequest := iconRequest(fastDocument, 96) + slowRequest := iconRequest(slowDocument, 96) + slowStarted := make(chan struct{}) + releaseSlow := make(chan struct{}) + + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + ctx context.Context, + request IconRequest, + ) (IconFetchResponse, error) { + if request.Reference == slowRequest.Reference { + close(slowStarted) + select { + case <-releaseSlow: + case <-ctx.Done(): + return IconFetchResponse{}, ctx.Err() + } + return iconResponse(slowDocument), nil + } + return iconResponse(fastDocument), nil + })) + + if _, err := cache.Load(context.Background(), fastRequest); err != nil { + t.Fatalf("Load(seed memory) error = %v", err) + } + slowOutcome := loadIconAsync(cache, context.Background(), slowRequest) + waitSignal(t, slowStarted, "slow fetch did not start") + + fastOutcome := loadIconAsync(cache, context.Background(), fastRequest) + select { + case outcome := <-fastOutcome: + if outcome.err != nil || outcome.result.Source != IconSourceMemory { + t.Fatalf("memory outcome = %#v, %v", outcome.result, outcome.err) + } + case <-time.After(2 * time.Second): + close(releaseSlow) + t.Fatal("memory hit waited for an unrelated slow fetch") + } + + close(releaseSlow) + if outcome := waitOutcome(t, slowOutcome); outcome.err != nil { + t.Fatalf("slow Load() error = %v", outcome.err) + } +} + +func TestIconCacheFetchesDifferentKeysConcurrently(t *testing.T) { + documents := [][]byte{testPNG(t, 17, 16), testPNG(t, 18, 16)} + requests := []IconRequest{ + iconRequest(documents[0], 96), + iconRequest(documents[1], 96), + } + documentByReference := map[string][]byte{ + requests[0].Reference: documents[0], + requests[1].Reference: documents[1], + } + started := make(chan string, len(requests)) + release := make(chan struct{}) + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + ctx context.Context, + request IconRequest, + ) (IconFetchResponse, error) { + started <- request.Reference + select { + case <-release: + return iconResponse(documentByReference[request.Reference]), nil + case <-ctx.Done(): + return IconFetchResponse{}, ctx.Err() + } + })) + + first := loadIconAsync(cache, context.Background(), requests[0]) + waitSignal(t, started, "first fetch did not start") + second := loadIconAsync(cache, context.Background(), requests[1]) + waitSignal(t, started, "second key did not fetch while first key was blocked") + close(release) + + for index, outcomeChannel := range []<-chan iconLoadOutcome{first, second} { + outcome := waitOutcome(t, outcomeChannel) + if outcome.err != nil || outcome.result.Source != IconSourceRemote { + t.Fatalf("outcome %d = %#v, %v", index, outcome.result, outcome.err) + } + } +} + +func TestIconCacheCoalescesSameKeyAndReturnsIndependentBytes(t *testing.T) { + document := testPNG(t, 19, 16) + request := iconRequest(document, 96) + started := make(chan struct{}) + release := make(chan struct{}) + var fetchCalls int32 + root := t.TempDir() + cache := NewIconCache(root, IconFetchFunc(func( + ctx context.Context, + _ IconRequest, + ) (IconFetchResponse, error) { + atomic.AddInt32(&fetchCalls, 1) + close(started) + select { + case <-release: + return iconResponse(document), nil + case <-ctx.Done(): + return IconFetchResponse{}, ctx.Err() + } + })) + + leader := loadIconAsync(cache, context.Background(), request) + waitSignal(t, started, "leader fetch did not start") + + cache.mu.Lock() + follower := loadIconAsync(cache, context.Background(), request) + close(release) + cache.mu.Unlock() + + leaderOutcome := waitOutcome(t, leader) + followerOutcome := waitOutcome(t, follower) + if leaderOutcome.err != nil || followerOutcome.err != nil { + t.Fatalf("coalesced errors = %v, %v", leaderOutcome.err, followerOutcome.err) + } + if got := atomic.LoadInt32(&fetchCalls); got != 1 { + t.Fatalf("fetch calls = %d, want 1", got) + } + if !bytes.Equal(leaderOutcome.result.Bytes, followerOutcome.result.Bytes) { + t.Fatal("coalesced callers received different content") + } + leaderOutcome.result.Bytes[0] ^= 0xff + if bytes.Equal(leaderOutcome.result.Bytes, followerOutcome.result.Bytes) { + t.Fatal("coalesced callers shared a mutable backing array") + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 1 { + t.Fatalf("disk entries = %v, error=%v; want one", entries, err) + } +} + +func TestIconCacheFollowerCancellationDoesNotCancelLeader(t *testing.T) { + document := testPNG(t, 20, 16) + request := iconRequest(document, 96) + started := make(chan struct{}) + release := make(chan struct{}) + var fetchCalls int32 + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + ctx context.Context, + _ IconRequest, + ) (IconFetchResponse, error) { + atomic.AddInt32(&fetchCalls, 1) + close(started) + select { + case <-release: + return iconResponse(document), nil + case <-ctx.Done(): + return IconFetchResponse{}, ctx.Err() + } + })) + + leader := loadIconAsync(cache, context.Background(), request) + waitSignal(t, started, "leader fetch did not start") + followerContext, cancelFollower := context.WithCancel(context.Background()) + cancelFollower() + if _, err := cache.Load(followerContext, request); !errors.Is(err, context.Canceled) { + t.Fatalf("follower error = %v, want context canceled", err) + } + if got := atomic.LoadInt32(&fetchCalls); got != 1 { + t.Fatalf("fetch calls after follower cancel = %d, want 1", got) + } + + close(release) + if outcome := waitOutcome(t, leader); outcome.err != nil { + t.Fatalf("leader error = %v", outcome.err) + } +} + +func TestIconCacheLeaderCancellationClearsFlightForRetry(t *testing.T) { + document := testPNG(t, 21, 16) + request := iconRequest(document, 96) + firstStarted := make(chan struct{}) + var fetchCalls int32 + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + ctx context.Context, + _ IconRequest, + ) (IconFetchResponse, error) { + call := atomic.AddInt32(&fetchCalls, 1) + if call == 1 { + close(firstStarted) + <-ctx.Done() + return IconFetchResponse{}, ctx.Err() + } + return iconResponse(document), nil + })) + + leaderContext, cancelLeader := context.WithCancel(context.Background()) + first := loadIconAsync(cache, leaderContext, request) + waitSignal(t, firstStarted, "cancelable leader did not start") + cancelLeader() + if outcome := waitOutcome(t, first); !errors.Is(outcome.err, context.Canceled) { + t.Fatalf("leader error = %v, want context canceled", outcome.err) + } + + cache.mu.Lock() + flights := len(cache.inflight) + cache.mu.Unlock() + if flights != 0 { + t.Fatalf("in-flight entries after failure = %d, want 0", flights) + } + result, err := cache.Load(context.Background(), request) + if err != nil || result.Source != IconSourceRemote { + t.Fatalf("retry result = %#v, error=%v", result, err) + } + if got := atomic.LoadInt32(&fetchCalls); got != 2 { + t.Fatalf("fetch calls after retry = %d, want 2", got) + } +} + +func TestIconCacheBoundsAndClosesFetchedBodies(t *testing.T) { + document := bytes.Repeat([]byte{0x42}, 32) + request := iconRequest(document, 96) + tests := []struct { + name string + contentLength int64 + wantRead int64 + }{ + {name: "declared oversized", contentLength: 9, wantRead: 0}, + {name: "unknown oversized", contentLength: UnknownIconContentLength, wantRead: 9}, + {name: "underdeclared oversized", contentLength: 1, wantRead: 9}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := newTrackingIconBody(document) + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + context.Context, + IconRequest, + ) (IconFetchResponse, error) { + return IconFetchResponse{ + Body: body, + ContentLength: test.contentLength, + }, nil + })) + cache.maxBytes = 8 + + _, err := cache.Load(context.Background(), request) + if !errors.Is(err, ErrIconTooLarge) { + t.Fatalf("Load() error = %v, want %v", err, ErrIconTooLarge) + } + if got := atomic.LoadInt64(&body.bytesRead); got != test.wantRead { + t.Fatalf("body bytes read = %d, want %d", got, test.wantRead) + } + if got := atomic.LoadInt32(&body.closed); got != 1 { + t.Fatalf("body close calls = %d, want 1", got) + } + }) + } +} + +func TestIconCacheClosesFetchedBodyOnValidationFailure(t *testing.T) { + document := testPNG(t, 25, 16) + request := iconRequest(testPNG(t, 26, 16), 96) + body := newTrackingIconBody(document) + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + context.Context, + IconRequest, + ) (IconFetchResponse, error) { + return IconFetchResponse{ + Body: body, + ContentLength: int64(len(document)), + }, nil + })) + + _, err := cache.Load(context.Background(), request) + if !errors.Is(err, ErrIconHashMismatch) { + t.Fatalf("Load() error = %v, want %v", err, ErrIconHashMismatch) + } + if got := atomic.LoadInt64(&body.bytesRead); got != int64(len(document)) { + t.Fatalf("body bytes read = %d, want %d", got, len(document)) + } + if got := atomic.LoadInt32(&body.closed); got != 1 { + t.Fatalf("body close calls = %d, want 1", got) + } +} + +func TestIconCacheCanceledContextClosesBodyWithoutReading(t *testing.T) { + document := testPNG(t, 27, 16) + request := iconRequest(document, 96) + body := newTrackingIconBody(document) + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + context.Context, + IconRequest, + ) (IconFetchResponse, error) { + return IconFetchResponse{ + Body: body, + ContentLength: int64(len(document)), + }, nil + })) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := cache.Load(ctx, request) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Load() error = %v, want context canceled", err) + } + if got := atomic.LoadInt64(&body.bytesRead); got != 0 { + t.Fatalf("body bytes read = %d, want 0", got) + } + if got := atomic.LoadInt32(&body.closed); got != 1 { + t.Fatalf("body close calls = %d, want 1", got) + } +} + +func TestIconCacheRejectsInvalidFetchResponses(t *testing.T) { + document := testPNG(t, 28, 16) + request := iconRequest(document, 96) + t.Run("nil body", func(t *testing.T) { + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + context.Context, + IconRequest, + ) (IconFetchResponse, error) { + return IconFetchResponse{ContentLength: int64(len(document))}, nil + })) + _, err := cache.Load(context.Background(), request) + if !errors.Is(err, ErrIconResponseInvalid) { + t.Fatalf("Load() error = %v, want %v", err, ErrIconResponseInvalid) + } + }) + t.Run("invalid content length", func(t *testing.T) { + body := newTrackingIconBody(document) + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + context.Context, + IconRequest, + ) (IconFetchResponse, error) { + return IconFetchResponse{Body: body, ContentLength: -2}, nil + })) + _, err := cache.Load(context.Background(), request) + if !errors.Is(err, ErrIconResponseInvalid) { + t.Fatalf("Load() error = %v, want %v", err, ErrIconResponseInvalid) + } + if got := atomic.LoadInt64(&body.bytesRead); got != 0 { + t.Fatalf("body bytes read = %d, want 0", got) + } + if got := atomic.LoadInt32(&body.closed); got != 1 { + t.Fatalf("body close calls = %d, want 1", got) + } + }) +} + +type trackingIconBody struct { + reader *bytes.Reader + bytesRead int64 + closed int32 +} + +func newTrackingIconBody(document []byte) *trackingIconBody { + return &trackingIconBody{reader: bytes.NewReader(document)} +} + +func (body *trackingIconBody) Read(buffer []byte) (int, error) { + read, err := body.reader.Read(buffer) + atomic.AddInt64(&body.bytesRead, int64(read)) + return read, err +} + +func (body *trackingIconBody) Close() error { + atomic.AddInt32(&body.closed, 1) + return nil +} + +func loadIconAsync( + cache *IconCache, + ctx context.Context, + request IconRequest, +) <-chan iconLoadOutcome { + outcome := make(chan iconLoadOutcome, 1) + go func() { + result, err := cache.Load(ctx, request) + outcome <- iconLoadOutcome{result: result, err: err} + }() + return outcome +} + +func waitSignal[T any](t *testing.T, signal <-chan T, failure string) T { + t.Helper() + select { + case value := <-signal: + return value + case <-time.After(2 * time.Second): + t.Fatal(failure) + var zero T + return zero + } +} + +func waitOutcome(t *testing.T, outcome <-chan iconLoadOutcome) iconLoadOutcome { + t.Helper() + return waitSignal(t, outcome, "icon load did not finish") +} diff --git a/core/catalog/icon_cache_test.go b/core/catalog/icon_cache_test.go index 0bba92e..5f53ef6 100644 --- a/core/catalog/icon_cache_test.go +++ b/core/catalog/icon_cache_test.go @@ -9,6 +9,7 @@ import ( "image" "image/color" "image/png" + "io" "os" "path/filepath" "testing" @@ -22,9 +23,9 @@ func TestIconCacheUsesMemoryAndOfflineDisk(t *testing.T) { cache := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { + ) (IconFetchResponse, error) { fetchCalls++ - return document, nil + return iconResponse(document), nil })) result, err := cache.Load(context.Background(), request) @@ -46,8 +47,8 @@ func TestIconCacheUsesMemoryAndOfflineDisk(t *testing.T) { restarted := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { - return nil, offline + ) (IconFetchResponse, error) { + return IconFetchResponse{}, offline })) result, err = restarted.Load(context.Background(), request) if err != nil { @@ -65,9 +66,9 @@ func TestIconCacheSeparatesDPIKeys(t *testing.T) { cache := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { + ) (IconFetchResponse, error) { fetchCalls++ - return document, nil + return iconResponse(document), nil })) for _, dpi := range []int{96, 144} { @@ -133,8 +134,8 @@ func TestIconCacheRejectsUntrustedImages(t *testing.T) { cache := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { - return test.document, nil + ) (IconFetchResponse, error) { + return iconResponse(test.document), nil })) if test.configure != nil { test.configure(cache) @@ -161,8 +162,8 @@ func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) { online := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { - return document, nil + ) (IconFetchResponse, error) { + return iconResponse(document), nil })) if _, err := online.Load(context.Background(), request); err != nil { t.Fatalf("Load(seed) error = %v", err) @@ -181,9 +182,9 @@ func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) { repairing := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { + ) (IconFetchResponse, error) { repairs++ - return document, nil + return iconResponse(document), nil })) result, err := repairing.Load(context.Background(), request) if err != nil { @@ -199,8 +200,8 @@ func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) { offline := NewIconCache(root, IconFetchFunc(func( context.Context, IconRequest, - ) ([]byte, error) { - return nil, errors.New("offline") + ) (IconFetchResponse, error) { + return IconFetchResponse{}, errors.New("offline") })) _, err = offline.Load(context.Background(), request) if !errors.Is(err, ErrNoValidIcon) { @@ -227,6 +228,13 @@ func iconRequest(document []byte, dpi int) IconRequest { } } +func iconResponse(document []byte) IconFetchResponse { + return IconFetchResponse{ + Body: io.NopCloser(bytes.NewReader(document)), + ContentLength: int64(len(document)), + } +} + func testPNG(t *testing.T, width, height int) []byte { t.Helper() source := image.NewNRGBA(image.Rect(0, 0, width, height)) diff --git a/core/catalog/icon_memory.go b/core/catalog/icon_memory.go new file mode 100644 index 0000000..bf019e7 --- /dev/null +++ b/core/catalog/icon_memory.go @@ -0,0 +1,73 @@ +package catalog + +import "container/list" + +type iconMemoryEntry struct { + key string + document []byte +} + +type iconMemoryCache struct { + maxBytes int64 + maxEntries int + usedBytes int64 + entries map[string]*list.Element + order list.List +} + +func newIconMemoryCache(maxBytes int64, maxEntries int) *iconMemoryCache { + return &iconMemoryCache{ + maxBytes: maxBytes, + maxEntries: maxEntries, + entries: make(map[string]*list.Element), + } +} + +func (memory *iconMemoryCache) get(key string) ([]byte, bool) { + element, exists := memory.entries[key] + if !exists { + return nil, false + } + memory.order.MoveToFront(element) + entry := element.Value.(*iconMemoryEntry) + return append([]byte(nil), entry.document...), true +} + +func (memory *iconMemoryCache) put(key string, document []byte) { + if memory.maxBytes <= 0 || memory.maxEntries <= 0 { + return + } + stored := append([]byte(nil), document...) + if element, exists := memory.entries[key]; exists { + entry := element.Value.(*iconMemoryEntry) + memory.usedBytes -= int64(len(entry.document)) + entry.document = stored + memory.usedBytes += int64(len(stored)) + memory.order.MoveToFront(element) + } else { + entry := &iconMemoryEntry{key: key, document: stored} + element := memory.order.PushFront(entry) + memory.entries[key] = element + memory.usedBytes += int64(len(stored)) + } + + for memory.overLimit() { + memory.removeOldest() + } +} + +func (memory *iconMemoryCache) overLimit() bool { + return memory.usedBytes > memory.maxBytes || + len(memory.entries) > memory.maxEntries +} + +func (memory *iconMemoryCache) removeOldest() { + element := memory.order.Back() + if element == nil { + return + } + entry := element.Value.(*iconMemoryEntry) + delete(memory.entries, entry.key) + memory.usedBytes -= int64(len(entry.document)) + memory.order.Remove(element) +} diff --git a/core/catalog/icon_memory_test.go b/core/catalog/icon_memory_test.go new file mode 100644 index 0000000..7012424 --- /dev/null +++ b/core/catalog/icon_memory_test.go @@ -0,0 +1,124 @@ +package catalog + +import ( + "context" + "sync/atomic" + "testing" +) + +func TestIconMemoryCacheEvictsOldestEntryAndPromotesHits(t *testing.T) { + memory := newIconMemoryCache(64, 2) + memory.put("a", []byte{1, 2}) + memory.put("b", []byte{3, 4}) + if _, exists := memory.get("a"); !exists { + t.Fatal("expected a memory hit") + } + memory.put("c", []byte{5, 6}) + + if _, exists := memory.get("b"); exists { + t.Fatal("least-recently-used entry b was retained") + } + if _, exists := memory.get("a"); !exists { + t.Fatal("promoted entry a was evicted") + } + if _, exists := memory.get("c"); !exists { + t.Fatal("new entry c was evicted") + } + if memory.usedBytes != 4 || len(memory.entries) != 2 || memory.order.Len() != 2 { + t.Fatalf( + "memory state = bytes:%d entries:%d order:%d", + memory.usedBytes, + len(memory.entries), + memory.order.Len(), + ) + } +} + +func TestIconMemoryCacheTracksReplacementAndByteLimit(t *testing.T) { + memory := newIconMemoryCache(5, 10) + memory.put("a", []byte{1, 2, 3}) + memory.put("b", []byte{4, 5}) + memory.put("a", []byte{6, 7, 8, 9}) + + if _, exists := memory.get("b"); exists { + t.Fatal("byte limit did not evict the oldest entry") + } + document, exists := memory.get("a") + if !exists || len(document) != 4 { + t.Fatalf("replacement = %v, exists=%v", document, exists) + } + document[0] = 0xff + again, _ := memory.get("a") + if again[0] == 0xff { + t.Fatal("memory hit exposed the stored backing array") + } + if memory.usedBytes != 4 || len(memory.entries) != 1 { + t.Fatalf("memory state = bytes:%d entries:%d", memory.usedBytes, len(memory.entries)) + } +} + +func TestIconCacheLRUEvictionReloadsVerifiedDisk(t *testing.T) { + documents := [][]byte{ + testPNG(t, 22, 16), + testPNG(t, 23, 16), + testPNG(t, 24, 16), + } + requests := make([]IconRequest, len(documents)) + documentByReference := make(map[string][]byte, len(documents)) + for index, document := range documents { + requests[index] = iconRequest(document, 96) + documentByReference[requests[index].Reference] = document + } + var fetchCalls int32 + cache := NewIconCache(t.TempDir(), IconFetchFunc(func( + _ context.Context, + request IconRequest, + ) (IconFetchResponse, error) { + atomic.AddInt32(&fetchCalls, 1) + return iconResponse(documentByReference[request.Reference]), nil + })) + cache.memory.maxEntries = 2 + + for _, request := range requests[:2] { + if _, err := cache.Load(context.Background(), request); err != nil { + t.Fatalf("Load(seed) error = %v", err) + } + } + if result, err := cache.Load(context.Background(), requests[0]); err != nil || result.Source != IconSourceMemory { + t.Fatalf("promote result = %#v, error=%v", result, err) + } + if _, err := cache.Load(context.Background(), requests[2]); err != nil { + t.Fatalf("Load(evict) error = %v", err) + } + result, err := cache.Load(context.Background(), requests[1]) + if err != nil || result.Source != IconSourceDisk { + t.Fatalf("reload result = %#v, error=%v", result, err) + } + if got := atomic.LoadInt32(&fetchCalls); got != 3 { + t.Fatalf("fetch calls = %d, want 3", got) + } +} + +func TestNewIconCacheUsesBoundedMemoryDefaults(t *testing.T) { + cache := NewIconCache(t.TempDir(), nil) + if cache.memory.maxBytes != DefaultIconMemoryBytes || + cache.memory.maxEntries != DefaultIconMemoryEntries { + t.Fatalf( + "memory limits = %d bytes/%d entries", + cache.memory.maxBytes, + cache.memory.maxEntries, + ) + } +} + +func TestIconMemoryCacheCanBeDisabledWithNonPositiveLimits(t *testing.T) { + for _, memory := range []*iconMemoryCache{ + newIconMemoryCache(0, 1), + newIconMemoryCache(1, 0), + } { + memory.put("disabled", []byte{1}) + if len(memory.entries) != 0 || memory.usedBytes != 0 { + t.Fatalf("disabled memory retained state: %#v", memory) + } + } +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index beece58..8e934c5 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -45,7 +45,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`、`T-605`。Windows 安全路径阻断项已关闭;Phase 2 交叉审核已定稿并落成首个整改 `T-606`,下一步领取并实现图标缓存并发/有界读取/内存上限,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-606`。Windows 安全路径阻断项与 Phase 2 首个图标缓存资源整改已关闭;下一步按 Phase 2 交叉审核顺序落成后台图标结果经 application event 回到 UI goroutine 的接线任务,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 优先路径: @@ -53,7 +53,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 下一步实现 T-606,关闭正式图标接入前的并发与资源边界;随后按已定稿审核顺序串行落成后续整改,再恢复 T-302/T-303 和 Phase 4-6。 +5. 已完成 T-606:图标缓存按 key 去重、流式有界读取、memory LRU 与双 shell 图标剪枝;下一步按审核顺序落成 UI 线程事件接线整改,再串行处理其余整改与 T-302/T-303、Phase 4-6。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index b41bd16..fd0240e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -61,7 +61,7 @@ UI 固定交互模式: T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;500 项 viewport 测试验证只布局可见行。主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。 -T-204 图标链路为 `Catalog icon digest + DPI → IconFetcher → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 内存字节 → 后台 DecodeIcon → UI ApplyIcon(paint.ImageOp)`。磁盘与远端都重新校验;断网只使用已验证磁盘缓存。两个详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 +T-204/T-606 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → application event → UI ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;Catalog 快照删除 app 时两个 shell 剪枝对应 ImageOp。详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 ## 三、仓库目录结构 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 27390be..ec21918 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -26,6 +26,7 @@ - Gio 代码只出现在 `ui/gio/`;Windows 调用只出现在 `platform/windows/`,且必须有非 Windows stub,保证 `go test ./...` 在 Linux CI 可跑。 - 仅 Win10+ 存在的 Windows API 必须 LoadLibrary 动态加载、失败降级,不得成为 EXE 导入表强依赖。 - Gio Layout 每帧禁止 IO(磁盘/网络/哈希);后台任务只发布 application.Event,不直接改控件;控件状态按软件 ID 保存。 +- 图标 Fetcher 必须返回与 context 绑定的流,由 `IconCache` 在分配完整响应前执行声明长度拒绝与 `maxBytes+1` 有界读取;不得恢复为先读任意大 `[]byte` 再校验。缓存并发只允许按 key 去重,不得用横跨磁盘/网络的全局锁换取去重。 ## 3. 安全纪律(违反即安全事故) diff --git a/docs/api.md b/docs/api.md index cd14957..80bb660 100644 --- a/docs/api.md +++ b/docs/api.md @@ -84,11 +84,13 @@ Catalog `icon` v1 是 `sha256:<64 hex>` 内容引用,不是可直接请求的 UR 客户端缓存合约: 1. 请求键为 `(icon digest, DPI)`;DPI 接受 48~768 的整数值。 -2. 加载顺序为内存 → 磁盘 → 注入 Fetcher;磁盘文件名为 `-.icon`。 -3. 远端和磁盘字节都必须复核 SHA-256,并通过图片解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。 -4. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。 -5. 新进程断网时可读取再次验证成功的磁盘缓存;缓存损坏且远端不可用时返回 `no valid icon available`,UI 使用稳定占位图。 -6. 后台完成 `IconCache.Load` 与 `DecodeIcon` 后调用 Gio `ApplyIcon`;Layout 只复用内存 `paint.ImageOp`。 +2. 加载顺序为有界 memory LRU → 磁盘 → 注入的流式 Fetcher;磁盘文件名为 `-.icon`。memory 默认同时限制为 32 MiB 与 256 个 key,命中提升 LRU recency。 +3. 同一 key 的并发 miss 由一个 in-flight leader 执行 disk/fetch/validate/store,等待者复用结果;不同 key 不互相串行。等待者取消只结束自身等待,leader 失败/取消后必须释放 key 供后续重试。 +4. Fetcher 返回与请求 context 绑定的 `io.ReadCloser` 和可选声明长度。声明长度超过 2 MiB 默认上限时不读 body;未知或伪造长度仍只读 `maxBytes+1`,所有成功/失败路径都关闭 body。 +5. 远端和磁盘字节都必须复核 SHA-256,并通过图片完整解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。 +6. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。 +7. 新进程断网时可读取再次验证成功的磁盘缓存;缓存损坏且远端不可用时返回 `no valid icon available`,UI 使用稳定占位图。 +8. 后台完成 `IconCache.Load` 与 `DecodeIcon` 后只发布事件;Gio UI goroutine 消费事件后调用 `ApplyIcon`/Invalidate。Layout 只复用内存 `paint.ImageOp`,Catalog 移除 app 时两个 shell 同步剪枝对应 ImageOp。 ## 2. 标准软件包协议 v1(ZIP) diff --git a/docs/current-state.md b/docs/current-state.md index 073a1ee..49aba84 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,26 +13,26 @@ ## 当前快照 - 日期:2026-07-17 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604、T-605 已完成;Phase 2 首个审核整改 T-606 已落成待领取,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-606 已完成,T-302 继续暂后置 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 -- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、可信图标缓存和默认并发 2 的持久可恢复下载队列;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与内存图标 -- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件稳定性、详情/ApplyIcon 与平台 stub;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存,以及默认并发 2 的持久可恢复下载队列;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与随 Catalog 剪枝的内存图标 +- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件/图标剪枝稳定性、详情/ApplyIcon 与平台 stub;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;下一步领取 T-606,收口图标缓存按 key 并发、流式读取上限、内存 LRU 与双 shell 图标剪枝;其余 Phase 2/Phase 1 审核整改继续串行,T-302 继续后置 +- 当前 blocker:无;下一步按 `docs/review/phase2-review.md` 最终顺序落成图标后台结果经 application event 回到 UI goroutine 的接线任务;适配器契约、VisibleItems 快照、Phase 1 中央目录预扫描等继续串行,T-302 继续后置 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301、T-604 与 T-605 已完成;T-606 已落成待领取;其余审核整改尚未编号,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-606 已完成;其余审核整改尚未编号,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、图标缓存、可恢复下载队列与 Phase 1 安装安全原型 | -| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和内存图标 | -| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情和内存图标 | +| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、可恢复下载队列与 Phase 1 安装安全原型 | +| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和随 Catalog 剪枝的内存图标 | +| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情和随 Catalog 剪枝的内存图标 | | `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` | | `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`、`T-605`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-606`。 - 正在进行:无。 -- 下一个可领取任务:`T-606`(收紧图标缓存并发与内存边界),依赖 `T-204`、`T-605` 均已完成。 +- 下一个可领取任务:无;先按 `docs/review/phase2-review.md` 最终顺序落成 UI 线程事件接线整改任务。 ## 当前可运行内容 diff --git a/docs/review/phase2-review.md b/docs/review/phase2-review.md index c0a79a3..62e04e3 100644 --- a/docs/review/phase2-review.md +++ b/docs/review/phase2-review.md @@ -283,5 +283,5 @@ modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取 ## 任务落地追踪 -- `T-606` 已按最终处理顺序第 1 项落成:按 key in-flight 去重、Fetcher 流式有界读取、memory LRU 与 modern/win7 `shell.icons` 剪枝合并收口。 -- UI 线程投递、适配器交互契约、`VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;必须等待 T-606 完成并提交后再按顺序落成。 +- `T-606` 已完成最终处理顺序第 1 项:按 key in-flight 去重、Fetcher 流式有界读取、32 MiB/256-key memory LRU 与 modern/win7 `shell.icons` 剪枝已实现并通过完整双 workspace 闸门。 +- UI 线程投递、适配器交互契约、`VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;下一任务从 UI 线程投递开始,继续按顺序串行落成。 diff --git a/docs/routes.md b/docs/routes.md index dbf355e..1d1afcb 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -65,7 +65,7 @@ T-204 已落地的详情/图标约束: - 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。 - modern 与 Legacy 均显示版本、分类、简介、tags、状态、不可用原因、教程和主页文本;尚未接入的安装/启动/授权不伪装为已可执行操作。 -- 后台把已验证图标解码为 `image.Image` 后调用 `ApplyIcon`;该方法预建 `paint.ImageOp`,列表与详情 Layout 只绘制内存操作。 +- 后台把已验证图标解码为 `image.Image` 后发布完成事件;UI goroutine 消费事件再调用 `ApplyIcon`/Invalidate。`ApplyIcon` 预建 `paint.ImageOp`,列表与详情 Layout 只绘制内存操作;`SetItems` 在 Catalog 移除 app 时剪枝对应 ImageOp。 - 图标未命中或离线缓存不可用时显示非 emoji 的字母占位,不阻塞列表或详情。 ## 导航规则 diff --git a/docs/tasks/T-606.md b/docs/tasks/T-606.md index 5c9f527..6f1715e 100644 --- a/docs/tasks/T-606.md +++ b/docs/tasks/T-606.md @@ -3,12 +3,12 @@ id: T-606 title: 收紧图标缓存并发与内存边界 phase: 2 deps: [T-204, T-605] -status: TODO +status: DONE created: 2026-07-17 issue: null -context_ref: null +context_ref: 8873a5261db76b30db30521197e9ec898ca86077 claim_branch: null -work_branch: null +work_branch: agent/codex/T-606 write_paths: - docs/tasks/T-606.md - core/catalog/ @@ -88,3 +88,10 @@ Phase 2 交叉审核确认,T-204 的 `IconCache.Load` 从取得全局 mutex 到 - 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第一优先级整改落成任务;现有全局最大任务为 T-605,因此取 T-606。 - 2026-07-17:任务合并收口按 key in-flight、流式有界读取、memory LRU 与双 shell 图标剪枝;UI 线程投递、适配器契约、VisibleItems 快照和文件拆分继续按审核顺序串行拆分。 +- 2026-07-17:在 `agent/codex/T-606` 分支领取任务,基线为 `8873a5261db76b30db30521197e9ec898ca86077`;保持单 Agent 串行执行。 +- 2026-07-17:`IconFetcher` 改为返回 context-bound `io.ReadCloser` 与可选声明长度;`IconCache` 在分配完整响应前拒绝超大声明并只读取 `maxBytes+1`,成功、超限、取消和校验失败路径统一关闭 body。未增加 URL/CDN 字段或具体 HTTP 映射。 +- 2026-07-17:`IconCache` 全局 mutex 收缩为只保护 memory/LRU/in-flight 元数据;同 digest+DPI 使用单 leader,不同 key 的磁盘/网络工作可并行,follower 可独立取消,leader 失败后 flight 清理并允许重试。每个调用者与 LRU 都持有独立字节副本。 +- 2026-07-17:新增默认 32 MiB/256-key 的双上限 LRU,覆盖命中提升、替换计数、条目/字节淘汰、禁用边界与淘汰后 verified disk 重载;modern/win7 `SetItems` 同步剪枝已移除 app 的 ImageOp 并保留现存 app 图标。 +- 2026-07-17:定向验证通过:Go 1.20.14 `go vet ./catalog`、`go test -count=20 ./catalog`;modern/win7 `go test -count=5 ./ui/gio`。并发用例使用 channel/barrier 与 2 秒仅防挂死超时,覆盖跨 key 并行、memory hit 不受慢 key 阻塞、同 key 单 Fetch、follower/leader 取消、flight 重试、body 最大读取量和独立 backing array。 +- 2026-07-17:尝试 Go 1.20.14 `go test -race -count=1 ./catalog`;当前 Windows 环境缺少 GCC(`cgo: C compiler "gcc" not found`),race detector 不可用。按任务边界记录限制,未把它伪报为通过;确定性并发测试连续 20 次通过。 +- 2026-07-17:完整 `./scripts/verify_phase0.ps1` 通过,包含治理/上下文/边界/版本校验、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的 UI/平台测试及 Windows amd64 构建。