2 Commits
Author SHA1 Message Date
clawbot 49c5fd132f lint: adopt org-standard .golangci.yml and golangci-lint v2.12.2 (closes #14)
check / check (push) Failing after 1s
The old backend/.golangci.yml declared version "2" but used v1 schema
keys, so under v2 it never validated and its thresholds were inert: the
linter ran at defaults. Replace it verbatim with the org-standard file,
repin the Dockerfile.backend lint stage to golangci-lint v2.12.2, and
assert the config's sha256 as the first step of the backend lint target
so it cannot silently drift again -- a local hash check, no network.

The standard config surfaces findings only in the tests: the repeated
IP literals in middleware_test.go become named constants (goconst) and
its request switches to NewRequestWithContext (noctx). reportbuf.go's
gosec suppression gains a plain justification comment. The rest of the
backend, including the fx-based server lifecycle, is already clean.
TODO.md updated.

Model: opus-4-8
2026-09-21 17:05:25 +00:00
clawbot d7cf010e00 fix(server): shut down through fx so buffered reports flush (closes #22)
check / check (push) Successful in 1m11s
The server ran os.Exit at the end of its own goroutine, racing fx's
teardown and sometimes killing the process before reportbuf's OnStop
flushed — silently losing a full flush window of telemetry on every
restart, at exit 0. Shutdown now goes through fx.Shutdowner, so every
OnStop runs in order.

The http.Server is built synchronously in OnStart before the serving
goroutine, so shutdown can no longer race or nil-deref it. A listen
failure exits non-zero via fx.ExitCode(1). reportbuf's OnStop is guarded
by sync.Once. writeTimeout now exceeds the chi per-request budget so that
budget is reachable. Dead startupTime, exitCode, and cancelFunc fields
are gone. A new test asserts a buffered report reaches disk after the
lifecycle stops.

Model: opus-4-8
2026-09-21 18:47:12 +02:00
9 changed files with 235 additions and 134 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Lint stage — fast feedback on formatting and lint issues. The # Lint stage — fast feedback on formatting and lint issues. The
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so # golangci/golangci-lint image ships Go, gofmt, make and the linter, so
# nothing is installed here. # nothing is installed here.
# golangci/golangci-lint:v2.7.2 (2026-08-09) # golangci/golangci-lint:v2.12.2 (2026-08-10)
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src WORKDIR /src
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
+31 -7
View File
@@ -10,23 +10,41 @@
# Status # Status
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty: pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
src/main.js). Frontend is functional; backend is new and unmerged. workflow, and the backend repo standard files are all on `main`. Frontend and
backend are both functional. Working toward the 1.0.0 milestone by closing the
remaining repo-compliance issues on the tracker.
# Next Step # Next Step
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make Confirm the `.gitea/workflows/check.yml` run is green (main always green
check green, and merge the branch to main. The branch adds the backend (buffered policy). The workflow file is already on `main`; what is unverified is that its
zstd-compressed report storage), the CI workflow, and backend repo standard latest run passes.
files, so merging it also closes most compliance gaps.
# Completed Steps # Completed Steps
- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
fx instead of `os.Exit`, so every component's `OnStop` runs and buffered
reports are flushed to disk on `SIGTERM` — previously a full flush window of
telemetry was silently lost on every restart. The `http.Server` is now built
before its serving goroutine starts, so shutdown can no longer race or
nil-deref it; a listen failure exits non-zero via `fx.Shutdowner`; `reportbuf`
`OnStop` is idempotent; and `writeTimeout` now exceeds the chi per-request
budget so that budget is actually reachable. Dead `startupTime`, `exitCode`,
and `cancelFunc` fields were removed
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and - 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP, `IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
frame/sniff/referrer/permissions headers) registered before CORS, and frame/sniff/referrer/permissions headers) registered before CORS, and
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP` trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default) only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
digest); the previous config declared `version: "2"` but used v1 schema keys,
so every threshold in it was inert and its green result was meaningless.
`backend/Makefile`'s `lint` target now asserts the config's sha256 against the
canonical file first, so drift from the org standard fails the build instead
of silently degrading to defaults
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap - 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching viewports, `#pause-btn`). The pin button's hit area grows via matching
@@ -56,7 +74,7 @@ files, so merging it also closes most compliance gaps.
shims, README Entrypoints section shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow - 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
and backend repo standard files; backend Dockerfile fixed (Go 1.25, and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged) golangci-lint) and moved to repo root (feat/reportbuf-storage)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing - 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
fixes; nginx config extracted; port hardcoded to 8080 fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix, - 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
@@ -78,3 +96,9 @@ files, so merging it also closes most compliance gaps.
(main always green policy) (main always green policy)
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete - Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
it it
- Upstream fix needed in `sneak/prompts`: the org-standard `.golangci.yml`
enables `gomodguard`, which golangci-lint v2.12.2 reports as deprecated since
v2.12.0 and replaced by `gomodguard_v2`, so every backend lint run prints a
deprecation warning. The file is standardized and must never be edited in this
repo, so nothing can be done here beyond tracking it — tracked at
<https://git.eeqj.de/sneak/netwatch/issues/41>
+14 -12
View File
@@ -1,5 +1,9 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -14,19 +18,17 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings: lll:
lll: line-length: 88
line-length: 88 funlen:
funlen: lines: 80
lines: 80 statements: 50
statements: 50 cyclop:
cyclop: max-complexity: 15
max-complexity: 15 dupl:
dupl: threshold: 100
threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
+19
View File
@@ -10,6 +10,17 @@ GOLDFLAGS += -s -w
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH) GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
# macOS ships shasum rather than sha256sum.
SHA256SUM := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
# .golangci.yml is standardized org-wide and must never be edited here
# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
# v1 keys, which left every threshold in the file inert while the build
# stayed green. The lint target therefore asserts the file still matches
# the canonical copy byte for byte. The check is a local hash comparison:
# no network, no remote schema, nothing unpinned in the build path.
GOLANGCI_CONFIG_SHA256 := 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
.PHONY: all build test lint fmt fmt-check check docker hooks run clean .PHONY: all build test lint fmt fmt-check check docker hooks run clean
all: build all: build
@@ -22,6 +33,14 @@ test:
timeout 30 go test ./... timeout 30 go test ./...
lint: lint:
@actual=$$($(SHA256SUM) .golangci.yml | cut -d' ' -f1); \
if [ "$$actual" != "$(GOLANGCI_CONFIG_SHA256)" ]; then \
echo ".golangci.yml has drifted from the org standard."; \
echo " expected $(GOLANGCI_CONFIG_SHA256)"; \
echo " actual $$actual"; \
echo "Restore it verbatim from sneak/prompts; do not edit it."; \
exit 1; \
fi
golangci-lint run ./... golangci-lint run ./...
fmt: fmt:
+21 -12
View File
@@ -9,6 +9,15 @@ import (
"sneak.berlin/go/netwatch/internal/middleware" "sneak.berlin/go/netwatch/internal/middleware"
) )
const (
// loopbackPeer is a remote address inside the trusted-proxy allowlist.
loopbackPeer = "127.0.0.1:5000"
// forwardedIP is the client address presented via X-Forwarded-For.
forwardedIP = "203.0.113.7"
// realIP is the client address presented via X-Real-IP.
realIP = "203.0.113.9"
)
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix { func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper() t.Helper()
@@ -41,32 +50,32 @@ func clientIPCases() []clientIPCase {
return []clientIPCase{ return []clientIPCase{
{ {
name: "trusted proxy uses forwarded-for", name: "trusted proxy uses forwarded-for",
remoteAddr: "127.0.0.1:5000", remoteAddr: loopbackPeer,
xff: "203.0.113.7", xff: forwardedIP,
want: "203.0.113.7", want: forwardedIP,
}, },
{ {
name: "trusted proxy uses left-most of chain", name: "trusted proxy uses left-most of chain",
remoteAddr: "10.1.2.3:5000", remoteAddr: "10.1.2.3:5000",
xff: "203.0.113.7, 10.1.2.3", xff: forwardedIP + ", 10.1.2.3",
want: "203.0.113.7", want: forwardedIP,
}, },
{ {
name: "trusted proxy falls back to x-real-ip", name: "trusted proxy falls back to x-real-ip",
remoteAddr: "127.0.0.1:5000", remoteAddr: loopbackPeer,
xRealIP: "203.0.113.9", xRealIP: realIP,
want: "203.0.113.9", want: realIP,
}, },
{ {
name: "untrusted peer ignores forwarded-for", name: "untrusted peer ignores forwarded-for",
remoteAddr: "198.51.100.4:5000", remoteAddr: "198.51.100.4:5000",
xff: "203.0.113.7", xff: forwardedIP,
want: "198.51.100.4", want: "198.51.100.4",
}, },
{ {
name: "untrusted peer ignores x-real-ip", name: "untrusted peer ignores x-real-ip",
remoteAddr: "198.51.100.4:5000", remoteAddr: "198.51.100.4:5000",
xRealIP: "203.0.113.9", xRealIP: realIP,
want: "198.51.100.4", want: "198.51.100.4",
}, },
{ {
@@ -76,7 +85,7 @@ func clientIPCases() []clientIPCase {
}, },
{ {
name: "trusted proxy with garbage header uses peer", name: "trusted proxy with garbage header uses peer",
remoteAddr: "127.0.0.1:5000", remoteAddr: loopbackPeer,
xff: "not-an-ip", xff: "not-an-ip",
want: "127.0.0.1", want: "127.0.0.1",
}, },
@@ -119,7 +128,7 @@ func TestSecurityHeaders(t *testing.T) {
) )
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
want := map[string]string{ want := map[string]string{
+16 -8
View File
@@ -40,11 +40,12 @@ type Params struct {
// Buffer accumulates JSON lines in memory and flushes them // Buffer accumulates JSON lines in memory and flushes them
// to zstd-compressed files on disk. // to zstd-compressed files on disk.
type Buffer struct { type Buffer struct {
buf bytes.Buffer buf bytes.Buffer
dataDir string dataDir string
done chan struct{} done chan struct{}
log *slog.Logger log *slog.Logger
mu sync.Mutex mu sync.Mutex
stopOnce sync.Once
} }
// New creates a Buffer and registers lifecycle hooks to // New creates a Buffer and registers lifecycle hooks to
@@ -76,8 +77,13 @@ func New(
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(_ context.Context) error {
close(b.done) // stopOnce makes OnStop idempotent: a second
b.flushLocked() // invocation must not close an already-closed channel
// (which would panic) or flush again.
b.stopOnce.Do(func() {
close(b.done)
b.flushLocked()
})
return nil return nil
}, },
@@ -163,7 +169,9 @@ func (b *Buffer) writeFile(data []byte) {
name := fmt.Sprintf("reports-%s.jsonl.zst", ts) name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
path := filepath.Join(b.dataDir, name) path := filepath.Join(b.dataDir, name)
f, err := os.OpenFile( //nolint:gosec // path built from controlled dataDir + timestamp // path is built from the operator-supplied dataDir plus a
// generated timestamp, so it carries no external input.
f, err := os.OpenFile( //nolint:gosec // see comment above
path, path,
os.O_WRONLY|os.O_CREATE|os.O_EXCL, os.O_WRONLY|os.O_CREATE|os.O_EXCL,
filePerms, filePerms,
+69 -5
View File
@@ -1,13 +1,77 @@
package reportbuf_test package reportbuf_test
import ( import (
"os"
"strings"
"testing" "testing"
_ "sneak.berlin/go/netwatch/internal/reportbuf" "sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
) )
func TestImport(t *testing.T) { // TestFlushOnShutdown proves the flush-on-shutdown path: a
t.Parallel() // report appended after start but before the periodic flush
// Compilation check — verifies the package parses // window must reach disk when the fx lifecycle stops. This is
// and all imports resolve. // the exact case that silent data loss on restart used to
// destroy.
func TestFlushOnShutdown(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
var buf *reportbuf.Buffer
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Populate(&buf),
)
app.RequireStart()
err := buf.Append(map[string]string{"probe": "shutdown"})
if err != nil {
t.Fatalf("append report: %v", err)
}
// RequireStop runs the reportbuf OnStop hook, which is the
// only code path that flushes buffered reports on shutdown.
app.RequireStop()
if !hasReportFile(t, dir) {
t.Fatal("no report file on disk after shutdown; " +
"the buffered report was lost")
}
}
func hasReportFile(t *testing.T, dir string) bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read data dir: %v", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".jsonl.zst") {
info, statErr := e.Info()
if statErr != nil {
t.Fatalf("stat %s: %v", e.Name(), statErr)
}
if info.Size() > 0 {
return true
}
}
}
return false
} }
+30 -11
View File
@@ -5,20 +5,31 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
"go.uber.org/fx"
) )
const ( const (
readTimeout = 10 * time.Second readTimeout = 10 * time.Second
readHeaderTimeout = 5 * time.Second readHeaderTimeout = 5 * time.Second
writeTimeout = 10 * time.Second
idleTimeout = 60 * time.Second idleTimeout = 60 * time.Second
maxHeaderBytes = 1 << 20 // 1 MiB maxHeaderBytes = 1 << 20 // 1 MiB
// requestTimeout (routes.go) is the single per-request
// processing budget, enforced by chi's middleware.Timeout.
// writeTimeout must exceed that budget so a handler can write
// its 503 when the chi timeout fires; if it were shorter the
// server would abort the write first and the chi budget would
// be unreachable dead configuration.
writeTimeout = requestTimeout + 5*time.Second
) )
func (s *Server) serveUntilShutdown() { // newHTTPServer constructs the http.Server. It performs no I/O
// and does not start listening.
func (s *Server) newHTTPServer() *http.Server {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{ return &http.Server{
Addr: listenAddr, Addr: listenAddr,
Handler: s, Handler: s,
MaxHeaderBytes: maxHeaderBytes, MaxHeaderBytes: maxHeaderBytes,
@@ -27,21 +38,29 @@ func (s *Server) serveUntilShutdown() {
WriteTimeout: writeTimeout, WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout, IdleTimeout: idleTimeout,
} }
}
s.SetupRoutes() // listenAndServe runs the listener until the server is shut
// down. A genuine listen failure (not the expected
// ErrServerClosed from a clean shutdown) requests process
// shutdown through fx with a non-zero exit code, so the failure
// is visible to any supervisor.
func (s *Server) listenAndServe() {
s.log.Info("http begin listen", s.log.Info("http begin listen",
"listenaddr", listenAddr, "listenaddr", s.httpServer.Addr,
"version", s.params.Globals.Version, "version", s.params.Globals.Version,
"buildarch", s.params.Globals.Buildarch, "buildarch", s.params.Globals.Buildarch,
) )
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) return
}
if s.cancelFunc != nil { s.log.Error("listen error", "error", err)
s.cancelFunc()
} shutdownErr := s.shutdowner.Shutdown(fx.ExitCode(1))
if shutdownErr != nil {
s.log.Error("request shutdown failed", "error", shutdownErr)
} }
} }
+33 -77
View File
@@ -1,16 +1,14 @@
// Package server provides the HTTP server lifecycle, // Package server provides the HTTP server lifecycle,
// including startup, routing, signal handling, and graceful // including startup, routing, and graceful shutdown. The
// shutdown. // process lifetime is owned by fx: shutdown is requested
// through fx.Shutdowner so every component's OnStop hook runs
// in dependency order.
package server package server
import ( import (
"context" "context"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
@@ -31,19 +29,18 @@ type Params struct {
Handlers *handlers.Handlers Handlers *handlers.Handlers
Logger *logger.Logger Logger *logger.Logger
Middleware *middleware.Middleware Middleware *middleware.Middleware
Shutdowner fx.Shutdowner
} }
// Server is the top-level HTTP server orchestrator. // Server is the top-level HTTP server orchestrator.
type Server struct { type Server struct {
cancelFunc context.CancelFunc h *handlers.Handlers
exitCode int httpServer *http.Server
h *handlers.Handlers log *slog.Logger
httpServer *http.Server mw *middleware.Middleware
log *slog.Logger params Params
mw *middleware.Middleware router *chi.Mux
params Params shutdowner fx.Shutdowner
router *chi.Mux
startupTime time.Time
} }
// New creates a Server and registers lifecycle hooks for // New creates a Server and registers lifecycle hooks for
@@ -57,23 +54,25 @@ func New(
s.mw = params.Middleware s.mw = params.Middleware
s.h = params.Handlers s.h = params.Handlers
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.shutdowner = params.Shutdowner
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
s.startupTime = time.Now().UTC() // Build the router and http.Server synchronously
// here, before spawning the serving goroutine, so
// httpServer is fully constructed by the time OnStop
// (or an early signal) can read it. fx guarantees
// OnStart returns before OnStop runs, so no
// synchronization or nil check is needed at shutdown.
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go func() { //nolint:contextcheck // fx OnStart ctx is startup-only; run() creates its own go s.listenAndServe()
s.run()
}()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
if s.cancelFunc != nil { return s.shutdown(ctx)
s.cancelFunc()
}
return nil
}, },
}) })
@@ -88,60 +87,17 @@ func (s *Server) ServeHTTP(
s.router.ServeHTTP(w, r) s.router.ServeHTTP(w, r)
} }
func (s *Server) run() { // shutdown gracefully stops the HTTP server within the
exitCode := s.serve() // deadline of the context fx provides for OnStop.
os.Exit(exitCode) func (s *Server) shutdown(ctx context.Context) error {
} err := s.httpServer.Shutdown(ctx)
func (s *Server) serve() int {
var ctx context.Context //nolint:wsl // ctx must be declared before multi-assign
ctx, s.cancelFunc = context.WithCancel(
context.Background(),
)
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 func() {
s.serveUntilShutdown()
}()
<-ctx.Done()
s.cleanShutdown()
return s.exitCode
}
const shutdownTimeout = 5 * time.Second
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(),
shutdownTimeout,
)
defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown)
if err != nil { if err != nil {
s.log.Error( s.log.Error("server clean shutdown failed", "error", err)
"server clean shutdown failed",
"error", err, return err
)
} }
s.log.Info("server stopped") s.log.Info("server stopped")
return nil
} }