Author SHA1 Message Date
clawbot 157bfbdd33 Stamp the tag or short commit in a plain docker build (closes #166)
check / check (push) Successful in 3m45s
.dockerignore now lets .git into the build context, without
.git/config, which can hold a remote URL with a credential. ARG VERSION
has no default: given none, the build stage takes the version from
git describe --tags --always, and fails if the context carries .git
and no version comes out. pixad now logs its name, version and
architecture as its first log line, through the existing
Logger.Identify, which nothing called.

Model: opus-5-5
2026-10-02 03:37:27 +00:00
14 changed files with 91 additions and 381 deletions
+3 -4
View File
@@ -238,7 +238,7 @@ variables set by the file's `env:` section are checked the same way.
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` | | `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB | | `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` | | `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` | | `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set | | `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username | | `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it | | `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
@@ -247,9 +247,8 @@ variables set by the file's `env:` section are checked the same way.
Key settings in more detail: Key settings in more detail:
- `access_control_allow_origin` — the origin a browser lets read the responses - `access_control_allow_origin` — the origin a browser lets read pixa's
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
default, is any site; otherwise one `http` or `https` origin such as default, is any site; otherwise one `http` or `https` origin such as
`https://example.com`, whose host is a lowercase host name (letters, `https://example.com`, whose host is a lowercase host name (letters,
digits, hyphens and dots, with a letter in its last part) or an IP address digits, hyphens and dots, with a letter in its last part) or an IP address
-17
View File
@@ -29,23 +29,6 @@ P2: security: referer blacklist
# Completed Steps # Completed Steps
- 2026-10-03 shutdown sets the exit code and waits for image processing
(closes #86): fx alone handles SIGINT and SIGTERM, and the server's own
signal handler is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's
code: 0 for a signal, 1 when the HTTP server cannot listen or the app fails
to start or to stop; the server's stop hook, which fx waits for, stops the
HTTP server, waits for the images still being processed, both within 5
seconds, then flushes Sentry; images still being processed after that are
logged with their count and make the exit code 1; a Sentry DSN that cannot be
used fails startup, so the stop hooks of what had already started run,
instead of exiting the process from a goroutine; the eviction loop is left to
#102.
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
middleware, with the `access_control_allow_origin` origin, moved from the
router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
still answers a preflight `OPTIONS` request; the login and URL generator
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
documented in `README.md` and `config.example.yml`.
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not - 2026-10-02 a plain `docker build .` stamps the tag or short commit, not
`dev` (closes #166): `.dockerignore` lets `.git` into the build context, `dev` (closes #166): `.dockerignore` lets `.git` into the build context,
without `.git/config`; with no `VERSION` build argument the `Dockerfile` without `.git/config`; with no `VERSION` build argument the `Dockerfile`
-5
View File
@@ -4,8 +4,6 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"os/signal"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uber.org/fx" "go.uber.org/fx"
@@ -47,9 +45,6 @@ func run(_ *cobra.Command, _ []string) {
_ = os.Setenv("PIXA_CONFIG_PATH", configPath) _ = os.Setenv("PIXA_CONFIG_PATH", configPath)
} }
// A write to a closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
fx.New( fx.New(
fx.Provide( fx.Provide(
config.New, config.New,
+2 -3
View File
@@ -103,9 +103,8 @@ upstream_max_response_size: 52428800
# longer than upstream_fetch_timeout plus 20 seconds. # longer than upstream_fetch_timeout plus 20 seconds.
downstream_timeout: 60s downstream_timeout: 60s
# The origin a browser lets read the responses of the image routes, # The origin a browser lets read pixa's responses, sent as the CORS
# /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin # Access-Control-Allow-Origin header: "*" (the default) is any site;
# header; no other route sends it. "*" (the default) is any site;
# otherwise one http or https origin such as https://example.com, whose # otherwise one http or https origin such as https://example.com, whose
# host is a lowercase host name (letters, digits, hyphens and dots, with a # host is a lowercase host name (letters, digits, hyphens and dots, with a
# letter in its last part) or an IP address (IPv6 in brackets, in its # letter in its last part) or an IP address (IPv6 in brackets, in its
-6
View File
@@ -81,12 +81,6 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
return s, nil return s, nil
} }
// WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then.
func (s *Handlers) WaitForProcessing(ctx context.Context) int {
return s.imgSvc.WaitForProcessing(ctx)
}
// initImageService initializes the image cache and service. // initImageService initializes the image cache and service.
func (s *Handlers) initImageService() error { func (s *Handlers) initImageService() error {
// Create the cache. cache_max_bytes: 0 disables the disk cache // Create the cache. cache_max_bytes: 0 disables the disk cache
-25
View File
@@ -331,31 +331,6 @@ func FormatToMIME(format Format) string {
} }
} }
// WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then. It
// waits by taking every slot in processingSemaphore as it frees up, so no
// new image starts meanwhile, and gives them all back before it returns.
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
taken := 0
defer func() {
for range taken {
<-p.processingSemaphore
}
}()
for taken < cap(p.processingSemaphore) {
select {
case p.processingSemaphore <- struct{}{}:
taken++
case <-ctx.Done():
return len(p.processingSemaphore) - taken
}
}
return 0
}
// acquireSlot takes a slot in processingSemaphore, waiting at most // acquireSlot takes a slot in processingSemaphore, waiting at most
// processingWaitTimeout for one to free up, and returns the func that gives // processingWaitTimeout for one to free up, and returns the func that gives
// it back. A free slot is taken even when ctx has ended; only the wait for // it back. A free slot is taken even when ctx has ended; only the wait for
@@ -300,69 +300,3 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
}) })
} }
} }
// 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)
}
}
-6
View File
@@ -195,12 +195,6 @@ func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
return s.cache.Stats(ctx) return s.cache.Stats(ctx)
} }
// WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then.
func (s *Service) WaitForProcessing(ctx context.Context) int {
return s.processor.WaitForProcessing(ctx)
}
// ValidateRequest validates the request signature if required. // ValidateRequest validates the request signature if required.
func (s *Service) ValidateRequest(req *ImageRequest) error { func (s *Service) ValidateRequest(req *ImageRequest) error {
// Check if host is allowed (no signature required) // Check if host is allowed (no signature required)
-64
View File
@@ -1,64 +0,0 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestCORSOnlyOnImageRoutes verifies that the image routes answer with the
// configured access_control_allow_origin, a preflight request included, and
// that the login and URL generator pages send no Access-Control-Allow-Origin,
// so no other site can read them. /metrics is left out: its middleware
// registers with the process-wide Prometheus registry, which only one test
// in this package can do.
func TestCORSOnlyOnImageRoutes(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
s := newTestServer(t)
s.config.AccessControlAllowOrigin = appOrigin
s.SetupRoutes()
requests := []struct {
method string
path string
want string
}{
{http.MethodGet, unsignedImagePath, appOrigin},
{http.MethodHead, unsignedImagePath, appOrigin},
{http.MethodOptions, unsignedImagePath, appOrigin},
{http.MethodGet, encryptedImagePath, appOrigin},
{http.MethodGet, "/", ""},
{http.MethodOptions, "/", ""},
{http.MethodPost, "/generate", ""},
{http.MethodGet, "/logout", ""},
}
for _, tc := range requests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
t.Context(), tc.method, tc.path, nil)
req.Header.Set("Origin", appOrigin)
// An OPTIONS request naming the method it asks about is the
// preflight a browser sends before some cross-origin requests.
if tc.method == http.MethodOptions {
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
t.Logf("status %d", rec.Code)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("Access-Control-Allow-Origin = %q, want %q",
got, tc.want)
}
})
}
}
+6 -8
View File
@@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
"go.uber.org/fx"
) )
// HTTP server configuration constants. // HTTP server configuration constants.
@@ -38,19 +36,19 @@ func (s *Server) newHTTPServer() *http.Server {
} }
} }
// serveUntilShutdown serves on s.httpServer until it is shut down. When it
// stops for any other reason, such as its port being in use, it asks fx to
// shut down with exit code 1.
func (s *Server) serveUntilShutdown() { func (s *Server) serveUntilShutdown() {
s.httpServer = s.newHTTPServer()
s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr) s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
err := s.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err) s.log.Error("listen error", "error", err)
err = s.shutdowner.Shutdown(fx.ExitCode(1)) if s.cancelFunc != nil {
if err != nil { s.cancelFunc()
s.log.Error("shutdown request failed", "error", err)
} }
} }
} }
+2 -6
View File
@@ -13,10 +13,6 @@ import (
// unsignedImagePath is an image URL that carries no signature. // unsignedImagePath is an image URL that carries no signature.
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg" const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
// encryptedImagePath is an encrypted image URL whose token cannot be
// decrypted.
const encryptedImagePath = "/v1/e/token/cat.jpg"
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance // TestMaintenanceModeRefusesImageRequests verifies that while maintenance
// mode is on, both image routes answer 503 Service Unavailable with a // mode is on, both image routes answer 503 Service Unavailable with a
// Retry-After header and the JSON error body the image handlers send. // Retry-After header and the JSON error body the image handlers send.
@@ -32,7 +28,7 @@ func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
}{ }{
{http.MethodGet, unsignedImagePath}, {http.MethodGet, unsignedImagePath},
{http.MethodHead, unsignedImagePath}, {http.MethodHead, unsignedImagePath},
{http.MethodGet, encryptedImagePath}, {http.MethodGet, "/v1/e/token/cat.jpg"},
} }
for _, tc := range requests { for _, tc := range requests {
@@ -99,7 +95,7 @@ func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
}{ }{
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized}, {http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized}, {http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
{http.MethodGet, encryptedImagePath, http.StatusBadRequest}, {http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
} }
for _, tc := range requests { for _, tc := range requests {
+15 -23
View File
@@ -38,6 +38,7 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.Metrics()) s.router.Use(s.mw.Metrics())
} }
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout)) s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
if s.sentryEnabled { if s.sentryEnabled {
@@ -73,31 +74,22 @@ func (s *Server) SetupRoutes() {
s.router.Get("/logout", s.h.HandleLogout()) s.router.Get("/logout", s.h.HandleLogout())
// Image routes, the only ones that send CORS headers, as pages on other // Image routes, refused while maintenance mode is on. Only these: the
// sites read them. They are a subrouter rather than a group: a group's // image's Docker HEALTHCHECK requests the health check, a 503 there
// middleware runs only for a request that matches one of its routes, // would make the container unhealthy, and upaas marks a deploy failed
// and a browser's preflight OPTIONS request matches none, so the CORS // when its container is unhealthy.
// middleware could not answer it. s.router.Group(func(r chi.Router) {
s.router.Route("/v1", func(r chi.Router) { r.Use(s.refuseDuringMaintenance)
r.Use(s.mw.CORS())
// Refused while maintenance mode is on. Only these: the image's // Main image proxy route
// Docker HEALTHCHECK requests the health check, a 503 there would // /v1/image/<host>/<path>/<width>x<height>.<format>
// make the container unhealthy, and upaas marks a deploy failed r.Get("/v1/image/*", s.h.HandleImage())
// when its container is unhealthy. r.Head("/v1/image/*", s.h.HandleImage())
r.Group(func(r chi.Router) {
r.Use(s.refuseDuringMaintenance)
// Main image proxy route // Encrypted image URL route
// /v1/image/<host>/<path>/<width>x<height>.<format> // The trailing filename (e.g., /img.jpg) is ignored but helps
r.Get("/image/*", s.h.HandleImage()) // browsers with content type
r.Head("/image/*", s.h.HandleImage()) r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
// Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
r.Get("/e/{token}/*", s.h.HandleImageEnc())
})
}) })
// Metrics endpoint with auth // Metrics endpoint with auth
+63 -52
View File
@@ -3,10 +3,12 @@ package server
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time" "time"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
@@ -25,10 +27,6 @@ const (
SentryFlushTimeout = 2 * time.Second SentryFlushTimeout = 2 * time.Second
) )
// errStillProcessing is returned by the server's stop hook when images are
// still being processed once ShutdownTimeout has passed.
var errStillProcessing = errors.New("images still being processed at shutdown")
// Params defines dependencies for Server. // Params defines dependencies for Server.
type Params struct { type Params struct {
fx.In fx.In
@@ -38,7 +36,6 @@ type Params struct {
Config *config.Config Config *config.Config
Middleware *middleware.Middleware Middleware *middleware.Middleware
Handlers *handlers.Handlers Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
} }
// Server is the main HTTP server. // Server is the main HTTP server.
@@ -48,58 +45,59 @@ type Server struct {
globals *globals.Globals globals *globals.Globals
mw *middleware.Middleware mw *middleware.Middleware
h *handlers.Handlers h *handlers.Handlers
shutdowner fx.Shutdowner
startupTime time.Time startupTime time.Time
exitCode int
sentryEnabled bool sentryEnabled bool
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
} }
// New creates a new Server instance. Its start hook starts Sentry and the // New creates a new Server instance.
// HTTP server; its stop hook, which fx runs on SIGINT, SIGTERM or a
// shutdown request, shuts them down.
func New(lc fx.Lifecycle, params Params) (*Server, error) { func New(lc fx.Lifecycle, params Params) (*Server, error) {
s := &Server{ s := &Server{
log: params.Logger.Get(), log: params.Logger.Get(),
config: params.Config, config: params.Config,
globals: params.Globals, globals: params.Globals,
mw: params.Middleware, mw: params.Middleware,
h: params.Handlers, h: params.Handlers,
shutdowner: params.Shutdowner,
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
s.startupTime = time.Now() s.startupTime = time.Now()
go s.Run(context.WithoutCancel(ctx))
err := s.enableSentry() return nil
if err != nil { },
return err OnStop: func(_ context.Context) error {
if s.cancelFunc != nil {
s.cancelFunc()
} }
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go s.serveUntilShutdown()
return nil return nil
}, },
OnStop: s.cleanShutdown,
}) })
return s, nil return s, nil
} }
// Run starts the server.
func (s *Server) Run(ctx context.Context) {
s.enableSentry()
s.serve(ctx)
}
// MaintenanceMode returns whether maintenance mode is enabled. // MaintenanceMode returns whether maintenance mode is enabled.
func (s *Server) MaintenanceMode() bool { func (s *Server) MaintenanceMode() bool {
return s.config.MaintenanceMode return s.config.MaintenanceMode
} }
func (s *Server) enableSentry() error { func (s *Server) enableSentry() {
s.sentryEnabled = false s.sentryEnabled = false
if s.config.SentryDSN == "" { if s.config.SentryDSN == "" {
return nil return
} }
err := sentry.Init(sentry.ClientOptions{ err := sentry.Init(sentry.ClientOptions{
@@ -107,42 +105,55 @@ func (s *Server) enableSentry() error {
Release: fmt.Sprintf("%s-%s", s.globals.Appname, s.globals.Version), Release: fmt.Sprintf("%s-%s", s.globals.Appname, s.globals.Version),
}) })
if err != nil { if err != nil {
return fmt.Errorf("sentry init failure: %w", err) s.log.Error("sentry init failure", "error", err)
os.Exit(1)
} }
s.log.Info("sentry error reporting activated") s.log.Info("sentry error reporting activated")
s.sentryEnabled = true s.sentryEnabled = true
return nil
} }
// cleanShutdown stops the HTTP server, waits for the images still being func (s *Server) serve(ctx context.Context) int {
// processed, then flushes Sentry. The first two share ShutdownTimeout. It ctx, cancelFunc := context.WithCancel(ctx)
// returns errStillProcessing when images are still being processed after s.cancelFunc = cancelFunc
// that, as their work is abandoned.
func (s *Server) cleanShutdown(ctx context.Context) error {
s.log.Info("shutting down")
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, ShutdownTimeout) go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
go s.serveUntilShutdown()
<-ctx.Done()
s.cleanShutdown(ctx)
return s.exitCode
}
func (s *Server) cleanShutdown(ctx context.Context) {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.WithoutCancel(ctx), ShutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown) if s.httpServer != nil {
if err != nil { err := s.httpServer.Shutdown(ctxShutdown)
s.log.Error("server clean shutdown failed", "error", err) if err != nil {
s.log.Error("server clean shutdown failed", "error", err)
}
} }
stillProcessing := s.h.WaitForProcessing(ctxShutdown)
if s.sentryEnabled { if s.sentryEnabled {
sentry.Flush(SentryFlushTimeout) sentry.Flush(SentryFlushTimeout)
} }
if stillProcessing > 0 {
s.log.Error("images still being processed at shutdown",
"count", stillProcessing)
return errStillProcessing
}
return nil
} }
-96
View File
@@ -1,96 +0,0 @@
package server
import (
"log/slog"
"net"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
)
// shutdownRecorder is an fx.Shutdowner that sends the options of each
// shutdown request on requests.
type shutdownRecorder struct {
requests chan []fx.ShutdownOption
}
func (r shutdownRecorder) Shutdown(opts ...fx.ShutdownOption) error {
r.requests <- opts
return nil
}
// TestSentryInitFailureFailsStartup checks that a Sentry DSN that cannot be
// used makes the server's start hook fail, so fx stops what has already
// started, instead of the process exiting from a goroutine.
func TestSentryInitFailureFailsStartup(t *testing.T) {
t.Parallel()
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
_, err = New(lc, Params{
Logger: log,
Globals: &globals.Globals{Appname: "pixad"},
Config: &config.Config{SentryDSN: "not-a-dsn"},
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
err = lc.Start(t.Context())
t.Logf("Start() error = %v", err)
if err == nil {
t.Fatal("Start() error = nil, want the Sentry initialization error")
}
}
// TestListenErrorRequestsShutdownWithExitCode1 occupies the server's port
// and checks that the listen error asks fx to shut down with exit code 1.
func TestListenErrorRequestsShutdownWithExitCode1(t *testing.T) {
t.Parallel()
busy, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("Listen() error = %v", err)
}
t.Cleanup(func() { _ = busy.Close() })
addr, ok := busy.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not a TCP address", busy.Addr())
}
requests := make(chan []fx.ShutdownOption, 1)
s := &Server{
log: slog.New(slog.DiscardHandler),
config: &config.Config{Port: addr.Port},
shutdowner: shutdownRecorder{requests: requests},
}
s.httpServer = s.newHTTPServer()
go s.serveUntilShutdown()
select {
case opts := <-requests:
t.Logf("shutdown options = %v", opts)
if len(opts) != 1 || opts[0] != fx.ExitCode(1) {
t.Errorf("shutdown options = %v, want [fx.ExitCode(1)]", opts)
}
case <-time.After(5 * time.Second):
t.Fatal("no shutdown was requested after the listen error")
}
}