From 7e6b9e7c2f84adeff758b6473f73e27f10d5acfc Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 08:07:19 +0800 Subject: [PATCH] autoresearch iter 4: stop one disconnected client from failing a shared image flight EnsureCompressedImageCache passed the arriving caller's request context into the singleflight body, which runs once for every concurrent requester of that cache key. If the first client disconnected, gin canceled the context, the shared generation aborted, and every follower received that failure and fell back to the uncompressed original. Detach cancellation with context.WithoutCancel so trace values still propagate but the shared work outlives any single requester. --- .../domain/upload/filesrv/file_server.go | 5 +- .../domain/upload/filesrv/file_server_test.go | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/backend/plugins/domain/upload/filesrv/file_server.go b/backend/plugins/domain/upload/filesrv/file_server.go index 8b4fe2e6..ef1a541f 100644 --- a/backend/plugins/domain/upload/filesrv/file_server.go +++ b/backend/plugins/domain/upload/filesrv/file_server.go @@ -227,8 +227,11 @@ func EnsureCompressedImageCache( return nil, false, fmt.Errorf("read compressed image cache: %w", err) } + // The flight body serves every concurrent requester for this key, so it must + // not die when whichever caller happened to arrive first disconnects. + flightCtx := context.WithoutCancel(ctx) result, err, _ := compressedImageFlight.Do(cacheKey, func() (any, error) { - return generateCompressedImageCache(ctx, upload, quality, cacheKey) + return generateCompressedImageCache(flightCtx, upload, quality, cacheKey) }) if err != nil { return nil, false, err diff --git a/backend/plugins/domain/upload/filesrv/file_server_test.go b/backend/plugins/domain/upload/filesrv/file_server_test.go index a9f617c1..ebd51f5c 100644 --- a/backend/plugins/domain/upload/filesrv/file_server_test.go +++ b/backend/plugins/domain/upload/filesrv/file_server_test.go @@ -24,6 +24,7 @@ import ( "path/filepath" "sync" "testing" + "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -55,7 +56,10 @@ func (s *localTestStorageService) Put(_ context.Context, key string, body io.Rea return contracts.StoragePutResult{Key: key, Bucket: "local"}, err } -func (s *localTestStorageService) Get(_ context.Context, key string) (*contracts.StorageObject, error) { +func (s *localTestStorageService) Get(ctx context.Context, key string) (*contracts.StorageObject, error) { + if err := ctx.Err(); err != nil { + return nil, err + } s.mu.RLock() defer s.mu.RUnlock() path := filepath.Join(s.root, key) @@ -357,6 +361,50 @@ func TestServeFileByIDImageCompression(t *testing.T) { }) } +// The singleflight body generates once on behalf of every concurrent requester +// sharing a cache key, so the caller that happens to arrive first must not be +// able to fail the others by disconnecting. +func TestEnsureCompressedImageCacheSurvivesCallerCancellation(t *testing.T) { + tempDir := t.TempDir() + shared.SetStorageService(&localTestStorageService{root: tempDir}) + + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + t.Fatalf("failed to encode test png: %v", err) + } + const filePath = "cancel_probe.png" + if err := os.WriteFile(filepath.Join(tempDir, filePath), pngBuf.Bytes(), 0o600); err != nil { + t.Fatalf("failed to write test png: %v", err) + } + + upload := &models.Upload{ + ID: 990001, + FilePath: filePath, + FileSize: int64(pngBuf.Len()), + MimeType: "image/png", + Extension: "png", + // Unique per run so the persistent disk cache can never serve this key. + Hash: fmt.Sprintf("cancel-probe-%d", time.Now().UnixNano()), + UpdatedAt: time.Now(), + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + webpBytes, cached, err := EnsureCompressedImageCache(ctx, upload, "medium") + if err != nil { + t.Fatalf("compressed image generation failed with a canceled caller context: %v", err) + } + if cached { + t.Errorf("expected a freshly generated image, got a cache hit") + } + if len(webpBytes) == 0 { + t.Errorf("expected non-empty webp bytes") + } +} + func TestNormalizeImageQuality(t *testing.T) { tests := []struct { name string