check / check (push) Successful in 9m56s
fx alone handles SIGINT and SIGTERM; the server's own handler, which only cancelled a context that fx's stop did not wait for, is gone. fx's Run exits with the shutdown's code: the one a shutdown request carries, 0 for a signal, 1 when the app fails to start or stop. A listen error asks fx to shut down with exit code 1. A Sentry DSN that cannot be used fails the server's start hook, so fx stops what had already started. The server's stop hook stops the HTTP server, then waits for the images still being processed, both within ShutdownTimeout; images still being processed after that are logged and fail the stop, so the exit code is 1. Model: opus-5-5
369 lines
9.1 KiB
Go
369 lines
9.1 KiB
Go
package imageprocessor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/iotest"
|
|
"time"
|
|
)
|
|
|
|
// errTestReadFailed is the error the unreadable test input returns.
|
|
var errTestReadFailed = errors.New("test input cannot be read")
|
|
|
|
// readingCounter counts the Process calls reading their input at the same
|
|
// time and remembers the most there ever were.
|
|
type readingCounter struct {
|
|
mu sync.Mutex
|
|
reading int
|
|
most int
|
|
}
|
|
|
|
func (c *readingCounter) start() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.reading++
|
|
c.most = max(c.most, c.reading)
|
|
}
|
|
|
|
func (c *readingCounter) stop() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.reading--
|
|
}
|
|
|
|
func (c *readingCounter) mostReading() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return c.most
|
|
}
|
|
|
|
// gatedReader is a Process input. Its first Read counts the call in,
|
|
// reports it on entered and blocks until gate is closed; it counts the call
|
|
// out when it returns io.EOF. Process reads its input only while it holds a
|
|
// processing slot, so the count never goes above MaxConcurrentProcessing.
|
|
type gatedReader struct {
|
|
data *bytes.Reader
|
|
gate <-chan struct{}
|
|
entered chan<- struct{}
|
|
counter *readingCounter
|
|
started bool
|
|
}
|
|
|
|
func (r *gatedReader) Read(p []byte) (int, error) {
|
|
if !r.started {
|
|
r.started = true
|
|
r.counter.start()
|
|
|
|
r.entered <- struct{}{}
|
|
|
|
<-r.gate
|
|
}
|
|
|
|
n, err := r.data.Read(p)
|
|
if errors.Is(err, io.EOF) {
|
|
r.counter.stop()
|
|
}
|
|
|
|
return n, err
|
|
}
|
|
|
|
// smallJPEGRequest asks for a 5x5 JPEG.
|
|
func smallJPEGRequest() *Request {
|
|
return &Request{
|
|
Size: Size{Width: 5, Height: 5},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
}
|
|
|
|
// processInBackground runs Process on reader in a new goroutine and sends
|
|
// its error on results.
|
|
func processInBackground(
|
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
|
) {
|
|
go func() {
|
|
result, err := proc.Process(context.Background(), reader, smallJPEGRequest())
|
|
if err == nil {
|
|
_ = result.Content.Close()
|
|
}
|
|
|
|
results <- err
|
|
}()
|
|
}
|
|
|
|
// waitForEntries fails the test unless count Process calls report on
|
|
// entered within a few seconds.
|
|
func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|
t.Helper()
|
|
|
|
for range count {
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Process calls did not start reading their input")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, limit := range []int{0, -1} {
|
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
|
limit, got, runtime.GOMAXPROCS(0))
|
|
}
|
|
}
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: 3})
|
|
if got := cap(proc.processingSemaphore); got != 3 {
|
|
t.Errorf("MaxConcurrentProcessing 3: %d slots, want 3", got)
|
|
}
|
|
}
|
|
|
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
|
// Process until the test lets them go. No more than the limit may be
|
|
// working at once, and the calls held back must wait for a slot and then
|
|
// succeed.
|
|
func TestProcessNeverExceedsMaxConcurrentProcessing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 2
|
|
calls = 6
|
|
)
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
|
input := createTestJPEG(t, 50, 50)
|
|
|
|
counter := &readingCounter{}
|
|
gate := make(chan struct{})
|
|
entered := make(chan struct{}, calls)
|
|
results := make(chan error, calls)
|
|
|
|
openGate := sync.OnceFunc(func() { close(gate) })
|
|
t.Cleanup(openGate)
|
|
|
|
for range calls {
|
|
processInBackground(proc, &gatedReader{
|
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
|
counter: counter,
|
|
}, results)
|
|
}
|
|
|
|
waitForEntries(t, entered, limit)
|
|
|
|
// A call beyond the limit would start reading its input now.
|
|
select {
|
|
case <-entered:
|
|
t.Fatalf("a Process call started while %d were already working", limit)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
openGate()
|
|
|
|
for range calls {
|
|
err := <-results
|
|
if err != nil {
|
|
t.Errorf("Process() error = %v, want nil once a slot is free", err)
|
|
}
|
|
}
|
|
|
|
if most := counter.mostReading(); most > limit {
|
|
t.Errorf("%d Process calls worked at once, want at most %d", most, limit)
|
|
}
|
|
}
|
|
|
|
// TestProcessWaitsThenFailsWhenNoSlotFrees holds the only slot and checks
|
|
// that another call waits the whole wait timeout, then fails with
|
|
// ErrTooManyImages instead of processing anyway.
|
|
func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: 1})
|
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
|
|
|
input := createTestJPEG(t, 10, 10)
|
|
|
|
gate := make(chan struct{})
|
|
entered := make(chan struct{}, 1)
|
|
held := make(chan error, 1)
|
|
|
|
openGate := sync.OnceFunc(func() { close(gate) })
|
|
t.Cleanup(openGate)
|
|
|
|
processInBackground(proc, &gatedReader{
|
|
data: bytes.NewReader(input), gate: gate, entered: entered,
|
|
counter: &readingCounter{},
|
|
}, held)
|
|
waitForEntries(t, entered, 1)
|
|
|
|
start := time.Now()
|
|
|
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
|
smallJPEGRequest())
|
|
if !errors.Is(err, ErrTooManyImages) {
|
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
|
}
|
|
|
|
if waited := time.Since(start); waited < proc.processingWaitTimeout {
|
|
t.Errorf("Process() failed after %v, before waiting %v",
|
|
waited, proc.processingWaitTimeout)
|
|
}
|
|
|
|
openGate()
|
|
|
|
err = <-held
|
|
if err != nil {
|
|
t.Errorf("Process() holding the slot: error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// TestProcessReleasesSlotOnError checks that Process gives its slot back
|
|
// when it fails, whether it fails early or late: with one slot, the slot
|
|
// must be free after the failure and the next call must succeed.
|
|
func TestProcessReleasesSlotOnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := createTestJPEG(t, 10, 10)
|
|
|
|
unsupported := smallJPEGRequest()
|
|
unsupported.Format = "bmp"
|
|
|
|
cases := []struct {
|
|
name string
|
|
input io.Reader
|
|
req *Request
|
|
// want is the error Process must return; nil means any error.
|
|
want error
|
|
}{
|
|
{
|
|
name: "input cannot be read",
|
|
input: iotest.ErrReader(errTestReadFailed),
|
|
req: smallJPEGRequest(),
|
|
want: errTestReadFailed,
|
|
},
|
|
{
|
|
name: "input over the byte limit",
|
|
input: bytes.NewReader(createTestJPEG(t, 800, 600)),
|
|
req: smallJPEGRequest(),
|
|
want: ErrInputDataTooLarge,
|
|
},
|
|
{
|
|
name: "input not an image",
|
|
input: strings.NewReader("not an image"),
|
|
req: smallJPEGRequest(),
|
|
},
|
|
{
|
|
name: "output format not supported",
|
|
input: bytes.NewReader(valid),
|
|
req: unsupported,
|
|
want: ErrUnsupportedOutputFormat,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
|
|
|
_, err := proc.Process(context.Background(), tc.input, tc.req)
|
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
|
}
|
|
|
|
if held := len(proc.processingSemaphore); held != 0 {
|
|
t.Fatalf("slot still held after the error: %d held", held)
|
|
}
|
|
|
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
|
smallJPEGRequest())
|
|
if err != nil {
|
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
|
}
|
|
|
|
_ = result.Content.Close()
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWaitForProcessing holds a processing slot with a Process call that
|
|
// cannot finish reading its input. WaitForProcessing must report that image
|
|
// when its context ends first, wait for it otherwise, return 0 once it has
|
|
// finished, and give back the slots it took while waiting.
|
|
func TestWaitForProcessing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proc := New(Params{MaxConcurrentProcessing: 2})
|
|
|
|
gate := make(chan struct{})
|
|
entered := make(chan struct{}, 1)
|
|
results := make(chan error, 1)
|
|
|
|
openGate := sync.OnceFunc(func() { close(gate) })
|
|
t.Cleanup(openGate)
|
|
|
|
processInBackground(proc, &gatedReader{
|
|
data: bytes.NewReader(createTestJPEG(t, 10, 10)), gate: gate,
|
|
entered: entered, counter: &readingCounter{},
|
|
}, results)
|
|
waitForEntries(t, entered, 1)
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
stillProcessing := proc.WaitForProcessing(ctx)
|
|
t.Logf("WaitForProcessing() after its context ended: %d", stillProcessing)
|
|
|
|
if stillProcessing != 1 {
|
|
t.Errorf("WaitForProcessing() after its context ended = %d, want 1",
|
|
stillProcessing)
|
|
}
|
|
|
|
waited := make(chan int, 1)
|
|
|
|
go func() { waited <- proc.WaitForProcessing(t.Context()) }()
|
|
|
|
select {
|
|
case got := <-waited:
|
|
t.Fatalf("WaitForProcessing() = %d while an image was being processed",
|
|
got)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
openGate()
|
|
|
|
err := <-results
|
|
if err != nil {
|
|
t.Errorf("Process() error = %v, want nil", err)
|
|
}
|
|
|
|
select {
|
|
case got := <-waited:
|
|
if got != 0 {
|
|
t.Errorf("WaitForProcessing() once processing finished = %d, want 0",
|
|
got)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("WaitForProcessing() did not return once processing finished")
|
|
}
|
|
|
|
if held := len(proc.processingSemaphore); held != 0 {
|
|
t.Errorf("%d slots still held after WaitForProcessing() returned", held)
|
|
}
|
|
}
|