mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2026-01-10 15:54:43 +01:00
* Initial plan * Fix deadlock in ConvertChapter when context is cancelled after wgConvertedPages.Add Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com> * Fix test comments to remove placeholder issue references Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
596 lines
16 KiB
Go
596 lines
16 KiB
Go
package webp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "golang.org/x/image/webp"
|
|
|
|
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
|
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func createTestImage(width, height int, format string) (image.Image, error) {
|
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
|
|
// Create a gradient pattern to ensure we have actual image data
|
|
for y := 0; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
img.Set(x, y, color.RGBA{
|
|
R: uint8((x * 255) / width),
|
|
G: uint8((y * 255) / height),
|
|
B: 100,
|
|
A: 255,
|
|
})
|
|
}
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
func encodeImage(img image.Image, format string) (*bytes.Buffer, string, error) {
|
|
buf := new(bytes.Buffer)
|
|
|
|
switch format {
|
|
case "jpeg", "jpg":
|
|
if err := jpeg.Encode(buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return buf, ".jpg", nil
|
|
case "gif":
|
|
if err := gif.Encode(buf, img, nil); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return buf, ".gif", nil
|
|
case "webp":
|
|
PrepareEncoder()
|
|
if err := Encode(buf, img, 80); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return buf, ".webp", nil
|
|
case "png":
|
|
fallthrough
|
|
default:
|
|
if err := png.Encode(buf, img); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return buf, ".png", nil
|
|
}
|
|
}
|
|
|
|
func createTestPage(t *testing.T, index int, width, height int, format string) *manga.Page {
|
|
img, err := createTestImage(width, height, format)
|
|
require.NoError(t, err)
|
|
|
|
buf, ext, err := encodeImage(img, format)
|
|
require.NoError(t, err)
|
|
|
|
return &manga.Page{
|
|
Index: uint16(index),
|
|
Contents: buf,
|
|
Extension: ext,
|
|
Size: uint64(buf.Len()),
|
|
}
|
|
}
|
|
|
|
func validateConvertedImage(t *testing.T, page *manga.Page) {
|
|
require.NotNil(t, page.Contents)
|
|
require.Greater(t, page.Contents.Len(), 0)
|
|
|
|
// Try to decode the image
|
|
img, format, err := image.Decode(bytes.NewReader(page.Contents.Bytes()))
|
|
require.NoError(t, err, "Failed to decode converted image")
|
|
|
|
if page.Extension == ".webp" {
|
|
assert.Equal(t, "webp", format, "Expected WebP format")
|
|
}
|
|
|
|
require.NotNil(t, img)
|
|
bounds := img.Bounds()
|
|
assert.Greater(t, bounds.Dx(), 0, "Image width should be positive")
|
|
assert.Greater(t, bounds.Dy(), 0, "Image height should be positive")
|
|
}
|
|
|
|
// TestConverter_ConvertChapter tests the ConvertChapter method of the WebP converter.
|
|
// It verifies various scenarios including:
|
|
// - Converting single normal images
|
|
// - Converting multiple normal images
|
|
// - Converting tall images with split enabled
|
|
// - Handling tall images that exceed maximum height
|
|
//
|
|
// For each test case it validates:
|
|
// - Proper error handling
|
|
// - Expected number of output pages
|
|
// - Correct page ordering
|
|
// - Split page handling and indexing
|
|
// - Progress callback behavior
|
|
//
|
|
// The test uses different image dimensions and split settings to ensure
|
|
// the converter handles all cases correctly while maintaining proper
|
|
// progress reporting and page ordering.
|
|
func TestConverter_ConvertChapter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pages []*manga.Page
|
|
split bool
|
|
expectSplit bool
|
|
expectError bool
|
|
numExpected int
|
|
}{
|
|
{
|
|
name: "Single normal image",
|
|
pages: []*manga.Page{createTestPage(t, 1, 800, 1200, "jpeg")},
|
|
split: false,
|
|
expectSplit: false,
|
|
numExpected: 1,
|
|
},
|
|
{
|
|
name: "Multiple normal images",
|
|
pages: []*manga.Page{
|
|
createTestPage(t, 1, 800, 1200, "png"),
|
|
createTestPage(t, 2, 800, 1200, "jpeg"),
|
|
createTestPage(t, 3, 800, 1200, "gif"),
|
|
},
|
|
split: false,
|
|
expectSplit: false,
|
|
numExpected: 3,
|
|
},
|
|
{
|
|
name: "Multiple normal images with webp",
|
|
pages: []*manga.Page{
|
|
createTestPage(t, 1, 800, 1200, "png"),
|
|
createTestPage(t, 2, 800, 1200, "jpeg"),
|
|
createTestPage(t, 3, 800, 1200, "gif"),
|
|
createTestPage(t, 4, 800, 1200, "webp"),
|
|
},
|
|
split: false,
|
|
expectSplit: false,
|
|
numExpected: 4,
|
|
},
|
|
{
|
|
name: "Tall image with split enabled",
|
|
pages: []*manga.Page{createTestPage(t, 1, 800, 5000, "jpeg")},
|
|
split: true,
|
|
expectSplit: true,
|
|
numExpected: 3, // Based on cropHeight of 2000
|
|
},
|
|
{
|
|
name: "Tall image without split",
|
|
pages: []*manga.Page{createTestPage(t, 1, 800, webpMaxHeight+100, "png")},
|
|
split: false,
|
|
expectError: true,
|
|
numExpected: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
chapter := &manga.Chapter{
|
|
Pages: tt.pages,
|
|
}
|
|
|
|
var progressMutex sync.Mutex
|
|
var lastProgress uint32
|
|
progress := func(message string, current uint32, total uint32) {
|
|
progressMutex.Lock()
|
|
defer progressMutex.Unlock()
|
|
assert.GreaterOrEqual(t, current, lastProgress, "Progress should never decrease")
|
|
lastProgress = current
|
|
assert.LessOrEqual(t, current, total, "Current progress should not exceed total")
|
|
}
|
|
|
|
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, tt.split, progress)
|
|
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
if convertedChapter != nil {
|
|
assert.LessOrEqual(t, len(convertedChapter.Pages), tt.numExpected)
|
|
}
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, convertedChapter)
|
|
assert.Len(t, convertedChapter.Pages, tt.numExpected)
|
|
|
|
// Validate all converted images
|
|
for _, page := range convertedChapter.Pages {
|
|
validateConvertedImage(t, page)
|
|
}
|
|
|
|
// Verify page order
|
|
for i := 1; i < len(convertedChapter.Pages); i++ {
|
|
prevPage := convertedChapter.Pages[i-1]
|
|
currPage := convertedChapter.Pages[i]
|
|
|
|
if prevPage.Index == currPage.Index {
|
|
assert.Less(t, prevPage.SplitPartIndex, currPage.SplitPartIndex,
|
|
"Split parts should be in ascending order for page %d", prevPage.Index)
|
|
} else {
|
|
assert.Less(t, prevPage.Index, currPage.Index,
|
|
"Pages should be in ascending order")
|
|
}
|
|
}
|
|
|
|
if tt.expectSplit {
|
|
splitFound := false
|
|
for _, page := range convertedChapter.Pages {
|
|
if page.IsSplitted {
|
|
splitFound = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, splitFound, "Expected to find at least one split page")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConverter_convertPage(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
format string
|
|
isToBeConverted bool
|
|
expectWebP bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "Convert PNG to WebP",
|
|
format: "png",
|
|
isToBeConverted: true,
|
|
expectWebP: true,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "Convert GIF to WebP",
|
|
format: "gif",
|
|
isToBeConverted: true,
|
|
expectWebP: true,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "Already WebP",
|
|
format: "webp",
|
|
isToBeConverted: true,
|
|
expectWebP: true,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "Skip conversion",
|
|
format: "png",
|
|
isToBeConverted: false,
|
|
expectWebP: false,
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
page := createTestPage(t, 1, 100, 100, tt.format)
|
|
img, err := createTestImage(100, 100, tt.format)
|
|
require.NoError(t, err)
|
|
container := manga.NewContainer(page, img, tt.format, tt.isToBeConverted)
|
|
|
|
converted, err := converter.convertPage(container, 80)
|
|
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
assert.Nil(t, converted)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, converted)
|
|
|
|
if tt.expectWebP {
|
|
assert.Equal(t, ".webp", converted.Page.Extension)
|
|
validateConvertedImage(t, converted.Page)
|
|
} else {
|
|
assert.NotEqual(t, ".webp", converted.Page.Extension)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConverter_convertPage_EncodingError(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
// Create a test case with nil image to test encoding error path
|
|
// when isToBeConverted is true but the image is nil, simulating a failure in the encoding step
|
|
corruptedPage := &manga.Page{
|
|
Index: 1,
|
|
Contents: &bytes.Buffer{}, // Empty buffer
|
|
Extension: ".png",
|
|
Size: 0,
|
|
}
|
|
|
|
container := manga.NewContainer(corruptedPage, nil, "png", true)
|
|
|
|
converted, err := converter.convertPage(container, 80)
|
|
|
|
// This should return nil container and error because encoding will fail with nil image
|
|
assert.Error(t, err)
|
|
assert.Nil(t, converted)
|
|
}
|
|
|
|
func TestConverter_checkPageNeedsSplit(t *testing.T) {
|
|
converter := New()
|
|
|
|
tests := []struct {
|
|
name string
|
|
imageHeight int
|
|
split bool
|
|
expectSplit bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "Normal height",
|
|
imageHeight: 1000,
|
|
split: true,
|
|
expectSplit: false,
|
|
},
|
|
{
|
|
name: "Height exceeds max with split enabled",
|
|
imageHeight: 5000,
|
|
split: true,
|
|
expectSplit: true,
|
|
},
|
|
{
|
|
name: "Height exceeds webp max without split",
|
|
imageHeight: webpMaxHeight + 100,
|
|
split: false,
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
page := createTestPage(t, 1, 800, tt.imageHeight, "jpeg")
|
|
|
|
needsSplit, img, format, err := converter.checkPageNeedsSplit(page, tt.split)
|
|
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, img)
|
|
assert.NotEmpty(t, format)
|
|
assert.Equal(t, tt.expectSplit, needsSplit)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConverter_Format(t *testing.T) {
|
|
converter := New()
|
|
assert.Equal(t, constant.WebP, converter.Format())
|
|
}
|
|
|
|
func TestConverter_ConvertChapter_Timeout(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
// Create a test chapter with a few pages
|
|
pages := []*manga.Page{
|
|
createTestPage(t, 1, 800, 1200, "jpeg"),
|
|
createTestPage(t, 2, 800, 1200, "png"),
|
|
createTestPage(t, 3, 800, 1200, "gif"),
|
|
}
|
|
|
|
chapter := &manga.Chapter{
|
|
FilePath: "/test/chapter.cbz",
|
|
Pages: pages,
|
|
}
|
|
|
|
var progressMutex sync.Mutex
|
|
var lastProgress uint32
|
|
progress := func(message string, current uint32, total uint32) {
|
|
progressMutex.Lock()
|
|
defer progressMutex.Unlock()
|
|
assert.GreaterOrEqual(t, current, lastProgress, "Progress should never decrease")
|
|
lastProgress = current
|
|
assert.LessOrEqual(t, current, total, "Current progress should not exceed total")
|
|
}
|
|
|
|
// Test with very short timeout (1 nanosecond)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1)
|
|
defer cancel()
|
|
|
|
convertedChapter, err := converter.ConvertChapter(ctx, chapter, 80, false, progress)
|
|
|
|
// Should return context error due to timeout
|
|
assert.Error(t, err)
|
|
assert.Nil(t, convertedChapter)
|
|
assert.Equal(t, context.DeadlineExceeded, err)
|
|
}
|
|
|
|
// TestConverter_ConvertChapter_ManyPages_NoDeadlock tests that converting chapters with many pages
|
|
// does not cause a deadlock. This test reproduces the scenario where processing
|
|
// many files with context cancellation could cause "all goroutines are asleep - deadlock!" error.
|
|
// The fix ensures that wgConvertedPages.Done() is called when context is cancelled after Add(1).
|
|
func TestConverter_ConvertChapter_ManyPages_NoDeadlock(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
// Create a chapter with many pages to increase the chance of hitting the race condition
|
|
numPages := 50
|
|
pages := make([]*manga.Page, numPages)
|
|
for i := 0; i < numPages; i++ {
|
|
pages[i] = createTestPage(t, i+1, 100, 100, "jpeg")
|
|
}
|
|
|
|
chapter := &manga.Chapter{
|
|
FilePath: "/test/chapter_many_pages.cbz",
|
|
Pages: pages,
|
|
}
|
|
|
|
progress := func(message string, current uint32, total uint32) {
|
|
// No-op progress callback
|
|
}
|
|
|
|
// Run multiple iterations to increase the chance of hitting the race condition
|
|
for iteration := 0; iteration < 10; iteration++ {
|
|
t.Run(fmt.Sprintf("iteration_%d", iteration), func(t *testing.T) {
|
|
// Use a very short timeout to trigger context cancellation during processing
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
|
defer cancel()
|
|
|
|
// This should NOT deadlock - it should return quickly with context error
|
|
done := make(chan struct{})
|
|
var convertErr error
|
|
go func() {
|
|
defer close(done)
|
|
_, convertErr = converter.ConvertChapter(ctx, chapter, 80, false, progress)
|
|
}()
|
|
|
|
// Wait with a reasonable timeout - if it takes longer than 5 seconds, we have a deadlock
|
|
select {
|
|
case <-done:
|
|
// Expected - conversion should complete (with error) quickly
|
|
assert.Error(t, convertErr, "Expected context error")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Deadlock detected: ConvertChapter did not return within 5 seconds")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConverter_ConvertChapter_ManyPages_WithSplit_NoDeadlock tests that converting chapters
|
|
// with many pages and split enabled does not cause a deadlock.
|
|
func TestConverter_ConvertChapter_ManyPages_WithSplit_NoDeadlock(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
// Create pages with varying heights, some requiring splits
|
|
numPages := 30
|
|
pages := make([]*manga.Page, numPages)
|
|
for i := 0; i < numPages; i++ {
|
|
height := 1000 // Normal height
|
|
if i%5 == 0 {
|
|
height = 5000 // Tall image that will be split
|
|
}
|
|
pages[i] = createTestPage(t, i+1, 100, height, "png")
|
|
}
|
|
|
|
chapter := &manga.Chapter{
|
|
FilePath: "/test/chapter_split_test.cbz",
|
|
Pages: pages,
|
|
}
|
|
|
|
progress := func(message string, current uint32, total uint32) {
|
|
// No-op progress callback
|
|
}
|
|
|
|
// Run multiple iterations with short timeouts
|
|
for iteration := 0; iteration < 10; iteration++ {
|
|
t.Run(fmt.Sprintf("iteration_%d", iteration), func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
|
defer cancel()
|
|
|
|
done := make(chan struct{})
|
|
var convertErr error
|
|
go func() {
|
|
defer close(done)
|
|
_, convertErr = converter.ConvertChapter(ctx, chapter, 80, true, progress) // split=true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
assert.Error(t, convertErr, "Expected context error")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Deadlock detected: ConvertChapter with split did not return within 5 seconds")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConverter_ConvertChapter_ConcurrentChapters_NoDeadlock simulates the scenario from the
|
|
// original bug report where multiple chapters are processed in parallel with parallelism > 1.
|
|
// This test ensures no deadlock occurs when multiple goroutines are converting chapters concurrently.
|
|
func TestConverter_ConvertChapter_ConcurrentChapters_NoDeadlock(t *testing.T) {
|
|
converter := New()
|
|
err := converter.PrepareConverter()
|
|
require.NoError(t, err)
|
|
|
|
// Create multiple chapters, each with many pages
|
|
numChapters := 20
|
|
pagesPerChapter := 30
|
|
chapters := make([]*manga.Chapter, numChapters)
|
|
|
|
for c := 0; c < numChapters; c++ {
|
|
pages := make([]*manga.Page, pagesPerChapter)
|
|
for i := 0; i < pagesPerChapter; i++ {
|
|
pages[i] = createTestPage(t, i+1, 100, 100, "jpeg")
|
|
}
|
|
chapters[c] = &manga.Chapter{
|
|
FilePath: fmt.Sprintf("/test/chapter_%d.cbz", c+1),
|
|
Pages: pages,
|
|
}
|
|
}
|
|
|
|
progress := func(message string, current uint32, total uint32) {}
|
|
|
|
// Process chapters concurrently with short timeouts (simulating parallelism flag)
|
|
parallelism := 4
|
|
var wg sync.WaitGroup
|
|
semaphore := make(chan struct{}, parallelism)
|
|
|
|
// Overall test timeout
|
|
testCtx, testCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer testCancel()
|
|
|
|
for _, chapter := range chapters {
|
|
wg.Add(1)
|
|
semaphore <- struct{}{} // Acquire
|
|
|
|
go func(ch *manga.Chapter) {
|
|
defer wg.Done()
|
|
defer func() { <-semaphore }() // Release
|
|
|
|
// Use very short timeout to trigger cancellation
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
|
defer cancel()
|
|
|
|
// This should not deadlock
|
|
_, _ = converter.ConvertChapter(ctx, ch, 80, false, progress)
|
|
}(chapter)
|
|
}
|
|
|
|
// Wait for all conversions with a timeout
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// All goroutines completed successfully
|
|
case <-testCtx.Done():
|
|
t.Fatal("Deadlock detected: Concurrent chapter conversions did not complete within 30 seconds")
|
|
}
|
|
}
|