Skip to content
26 changes: 14 additions & 12 deletions decoder/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ package decoder
*/
import "C"
import (
"bytes"
"errors"
"fmt"
"image"
Expand All @@ -47,22 +48,26 @@ type Decoder struct {
sPtr C.size_t
}

Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function documentation comment for NewDecoder was removed. Public functions should have documentation comments that start with the function name. The comment should be restored and potentially updated to document the new Options.ImageFactory and Options.Buffer fields and their usage patterns.

Suggested change
// NewDecoder creates a Decoder that reads all WebP data from r using the provided options.
//
// If options is nil, a zero-value Options is used. If options.ImageFactory is nil,
// it defaults to DefaultImageFactory.
//
// If options.Buffer is non-nil, it is used as the initial contents and capacity of the
// internal buffer before copying data from r. This allows callers to reuse a buffer to
// reduce allocations. The buffer contents are overwritten with the data read from r.

Copilot uses AI. Check for mistakes.
// NewDecoder return new decoder instance
func NewDecoder(r io.Reader, options *Options) (d *Decoder, err error) {
var data []byte
if options == nil {
options = &Options{}
}

if options.ImageFactory == nil {
options.ImageFactory = &DefaultImageFactory{}
}

if data, err = io.ReadAll(r); err != nil {
buf := bytes.NewBuffer(options.Buffer)

if _, err = io.Copy(buf, r); err != nil {
return nil, err
}

if len(data) == 0 {
if len(buf.Bytes()) == 0 {
return nil, errors.New("data is empty")
}

if options == nil {
options = &Options{}
}
d = &Decoder{data: data, options: options}
d = &Decoder{data: buf.Bytes(), options: options}
Comment on lines +60 to +70
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Buffer reuse mechanism doesn't work as designed. When Options.Buffer is passed in, a new bytes.Buffer wraps it and grows as needed via io.Copy. However, the grown buffer (buf.Bytes()) is stored internally in the Decoder but never returned to the caller. The caller only has the original Options.Buffer slice and puts that back in the pool, not the potentially grown buffer. This means the buffer pooling optimization doesn't actually reuse grown buffers. Consider either: 1) returning the grown buffer to the caller somehow (e.g., storing it back in Options.Buffer), or 2) changing the API to accept and return a *bytes.Buffer directly, or 3) documenting that the Buffer field is only used for initial capacity hints.

Copilot uses AI. Check for mistakes.

if d.config, err = d.options.GetConfig(); err != nil {
return nil, err
Expand All @@ -87,10 +92,7 @@ func (d *Decoder) Decode() (image.Image, error) {
d.config.output.colorspace = C.MODE_RGBA
d.config.output.is_external_memory = 1

img := image.NewNRGBA(image.Rectangle{Max: image.Point{
X: int(d.config.output.width),
Y: int(d.config.output.height),
}})
img := d.options.ImageFactory.Get(int(d.config.output.width), int(d.config.output.height))

buff := (*C.WebPRGBABuffer)(unsafe.Pointer(&d.config.output.u[0]))
buff.stride = C.int(img.Stride)
Expand Down
60 changes: 60 additions & 0 deletions decoder/decoder_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package decoder

import (
"bytes"
"image"
"os"
"testing"
)

func loadImage(b *testing.B) []byte {
filename := "../test_data/images/100x150_lossless.webp"
data, err := os.ReadFile(filename)
if err != nil {
b.Fatal(err)
}
return data
}

func BenchmarkDecodePooled(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()

data := loadImage(b)

imagePool := NewImagePool()
bufferPool := NewBufferPool()

b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
buf := bufferPool.Get()
decoder, err := NewDecoder(bytes.NewReader(data), &Options{ImageFactory: imagePool, Buffer: buf})
img, err := decoder.Decode()
Comment on lines +31 to +32
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned by NewDecoder is checked, but if it's non-nil, the benchmark will fatal. However, the subsequent err assignment from decoder.Decode() overwrites the first err variable without checking it. This means if NewDecoder returns an error, it won't be caught until decoder.Decode() is called on a nil decoder, causing a panic. Both errors should be checked separately or assigned to different variables.

Copilot uses AI. Check for mistakes.
if err != nil {
b.Fatal(err)
}

// put everything back
imagePool.Put(img.(*image.NRGBA))
bufferPool.Put(buf)
}
})
}

func BenchmarkDecodeUnPooled(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()

data := loadImage(b)

b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
decoder, err := NewDecoder(bytes.NewReader(data), &Options{})
img, err := decoder.Decode()
Comment on lines +52 to +53
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same error variable issue exists here. The error from NewDecoder is not checked before being overwritten by the error from decoder.Decode(), which could lead to a panic if NewDecoder fails.

Copilot uses AI. Check for mistakes.
if err != nil {
b.Fatal(err)
}
_ = img
}
})
}
5 changes: 3 additions & 2 deletions decoder/decoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@
package decoder

import (
"github.com/kolesa-team/go-webp/utils"
"github.com/stretchr/testify/require"
"image"
"os"
"testing"

"github.com/kolesa-team/go-webp/utils"
"github.com/stretchr/testify/require"
)

func TestNewDecoder(t *testing.T) {
Expand Down
20 changes: 20 additions & 0 deletions decoder/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ type Options struct {
Flip bool
DitheringStrength int
AlphaDitheringStrength int

// These two are optimizations that require a little extra work on the caller side.

// if nil, DefaultImageFactory will be used. If non-nil, decode will return an image that must be put back into the pool
// when you're done with it
ImageFactory ImageFactory
// if nil, a default buffer will be used. If non-nil, decode will use this buffer to store data from the reader.
// The idea is that this buffer be reused, so either pass this back in next time you call decode, or put it back into
// a pool when you're done with it.
Buffer []byte
}

// GetConfig build WebPDecoderConfig for libwebp
Expand Down Expand Up @@ -89,3 +99,13 @@ func (o *Options) GetConfig() (*C.WebPDecoderConfig, error) {

return &config, nil
}

type ImageFactory interface {
Get(width, height int) *image.NRGBA
}

type DefaultImageFactory struct{}

func (d *DefaultImageFactory) Get(width, height int) *image.NRGBA {
return image.NewNRGBA(image.Rect(0, 0, width, height))
}
75 changes: 75 additions & 0 deletions decoder/pool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package decoder

import (
"image"
"sync"
"sync/atomic"
)

type ImagePool struct {
poolMap map[int]*sync.Pool
lock *sync.Mutex
Count int64
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Count field is exported but lacks documentation. It appears to track the number of different dimension pools created. This field should either have a documentation comment explaining its purpose and whether it's safe to read/modify concurrently, or it should be unexported if it's only for internal use or debugging.

Suggested change
Count int64
// Count tracks the number of distinct dimension pools created. It is incremented
// atomically by ImagePool and may be read concurrently, but callers should treat it
// as read-only and must not modify it directly.
Count int64

Copilot uses AI. Check for mistakes.
}

func NewImagePool() *ImagePool {
return &ImagePool{
poolMap: make(map[int]*sync.Pool),
lock: &sync.Mutex{},
}
}

func (n *ImagePool) Get(width, height int) *image.NRGBA {
dimPool := n.getPool(width, height)

img := dimPool.Get().(*image.NRGBA)
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line will panic if the pool contains nil or a value that is not *image.NRGBA. Since sync.Pool doesn't guarantee the type of values returned, this type assertion should be checked to prevent runtime panics. Consider using a comma-ok idiom or ensuring the New function is always called for empty pools.

Copilot uses AI. Check for mistakes.
img.Rect.Max.X = width
img.Rect.Max.Y = height
Comment on lines +26 to +27
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifying the image rectangle after retrieval from the pool can lead to inconsistent state. If an image is retrieved from the pool with different dimensions than originally allocated, the Pix slice capacity might not match the new dimensions, potentially causing incorrect image data or index out of bounds errors when the image is used. The pool should only return images with the exact dimensions requested, not resize them after retrieval.

Copilot uses AI. Check for mistakes.
return img
}

func (n *ImagePool) getPool(width int, height int) *sync.Pool {
dim := width * height
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the product of width and height as the pool key can cause incorrect pooling when dimensions differ but have the same product (e.g., 100x150 and 150x100 both equal 15000). This would return images with incorrect dimensions, leading to data corruption or rendering errors. The key should incorporate both dimensions separately, such as using a composite key or encoding both values uniquely.

Copilot uses AI. Check for mistakes.

n.lock.Lock()
dimPool, ok := n.poolMap[dim]
if !ok {
atomic.AddInt64(&n.Count, 1)
dimPool = &sync.Pool{
New: func() interface{} {
return image.NewNRGBA(image.Rect(0, 0, width, height))
},
}
n.poolMap[dim] = dimPool
}
n.lock.Unlock()
Comment on lines +34 to +45
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock is released after the pool is retrieved, but before it's used to Get an image. This creates a race condition where the pool's New function might be called concurrently with map modifications. While sync.Pool is safe for concurrent access, the issue is that the New function captures width and height from the first allocation, which might not match subsequent requests with the same dimension product but different width/height combinations.

Copilot uses AI. Check for mistakes.
return dimPool
}

func (n *ImagePool) Put(img *image.NRGBA) {
dimPool := n.getPool(img.Rect.Dx(), img.Rect.Dy())
dimPool.Put(img)
}
Comment on lines +22 to +52
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The receiver variable name 'n' for ImagePool is unconventional. Go convention typically uses the first letter(s) of the type name, so 'ip' or 'p' would be more idiomatic. This should be consistent across all ImagePool methods.

Copilot uses AI. Check for mistakes.

type BufferPool struct {
pool *sync.Pool // pointer because noCopy
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "pointer because noCopy", but there's no noCopy field or implementation in the BufferPool struct. If the intention is to prevent copying of the BufferPool, a noCopy field should be added (sync.Pool itself is not supposed to be copied, but Go doesn't enforce this without an explicit noCopy marker). If the comment is incorrect, it should be removed or clarified.

Copilot uses AI. Check for mistakes.
}

func NewBufferPool() *BufferPool {
return &BufferPool{
pool: &sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
},
}
}

func (b *BufferPool) Get() []byte {
return b.pool.Get().([]byte)
}

func (b *BufferPool) Put(buf []byte) {
buf = buf[:0]
b.pool.Put(buf)
Comment on lines +72 to +74
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The buffer is truncated before being returned to the pool, which is good practice. However, there's no upper limit on buffer size before returning to the pool. If a buffer grows very large during use, it will remain large in the pool and consume excessive memory. Consider checking the buffer's capacity and only returning buffers below a certain threshold to the pool to prevent memory bloat.

Copilot uses AI. Check for mistakes.
}
Comment on lines +9 to +75
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ImagePool and BufferPool types lack test coverage. Given that the decoder package has existing test coverage (see decoder_test.go), tests should be added to verify the pool implementations work correctly, including edge cases like concurrent access, proper reuse of pooled objects, and correct handling of different image dimensions.

Copilot uses AI. Check for mistakes.
Loading