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