2 Commits
Author SHA1 Message Date
sneak d351151f45 Install lint-phase libraries with apt-get, not apk (closes #83)
check / check (push) Waiting to run
The note under the canonical Go Dockerfile example said to install
system libraries for linting with `apk add`, but the lint phase is
based on the Debian golangci-lint image, which has no apk. It now
gives the apt-get command with the Debian package name (libvips-dev)
and removes the package lists in the same RUN. No other canonical
sentence describes installing a library in the lint phase; the
remaining apk mentions concern the alpine build stage or the host.
Nothing is pinned or unpinned.

Model: opus-5-5
2026-10-04 09:03:40 +00:00
clawbot c32b10e77f Let fx own signals and the exit code in the server example (closes #86)
check / check (push) Waiting to run
The server lifecycle example in `prompts/GO_HTTP_SERVER_CONVENTIONS.md` dropped its exit code, installed its own SIGINT/SIGTERM handler beside the one fx's `Run()` installs, and exited from a goroutine when Sentry could not start, so no stop hook ran.

fx now owns signals and the exit code: `main` calls `Run()`; a listen error shuts fx down with `fx.ExitCode(1)` through `fx.Shutdowner`; `enableSentry()` returns its error from the start hook; the stop hook shuts the HTTP server down within 5 seconds, flushes Sentry, and fails when requests are still running. The start hook builds the HTTP server before the listen goroutine so the stop hook can reach it. A new paragraph says who owns signals and the exit code.

Model: opus-5-5
2026-10-04 11:02:18 +02:00
2 changed files with 64 additions and 57 deletions
+9
View File
@@ -27,6 +27,15 @@ fmt-check, and commit.
image is Debian-based and has no `apk`, so the old `apk add` instruction
failed as written. Nothing is pinned or unpinned; that is still open on
issue 72.
- 2026-10-04: Fixed the server lifecycle example in
`prompts/GO_HTTP_SERVER_CONVENTIONS.md` (issue 86). Only fx handles SIGINT and
SIGTERM, and `Run()` in `main` exits with the shutdown's exit code. A listen
error asks fx to shut down with exit code 1 through `fx.Shutdowner`; a Sentry
start failure is returned from the server's start hook instead of calling
`os.Exit` from a goroutine, so the stop hooks of what had started still run.
The server's stop hook shuts the HTTP server down within 5 seconds and fails
when requests are still running. A new paragraph says who owns signals and the
exit code.
- 2026-10-04: `REPO_POLICIES.md` now says how a Go tool a repo needs on the host
is pinned (issue 37): installed with `go install` pinned to a commit hash,
never tracked as a `go.mod` tool dependency or through a `tools.go` file.
+55 -57
View File
@@ -106,6 +106,9 @@ project-root/
package main
import (
"os/signal"
"syscall"
"yourproject/internal/config"
"yourproject/internal/database"
"yourproject/internal/globals"
@@ -126,6 +129,9 @@ func main() {
globals.Appname = Appname
globals.Version = Version
// A write to a closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
fx.New(
fx.Provide(
config.New,
@@ -198,7 +204,8 @@ Providers are resolved automatically by fx, but conceptually follow this order:
Database)
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
8. `server.New` - Server (depends on all above)
8. `server.New` - Server (depends on all above, and on `fx.Shutdowner`, which fx
provides itself)
---
@@ -217,16 +224,14 @@ type ServerParams struct {
Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
}
type Server struct {
startupTime time.Time
port int
exitCode int
sentryEnabled bool
log *slog.Logger
ctx context.Context
cancelFunc context.CancelFunc
httpServer *http.Server
router *chi.Mux
params ServerParams
@@ -248,13 +253,15 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s.startupTime = time.Now()
go s.Run()
return nil
},
OnStop: func(ctx context.Context) error {
// Server shutdown logic
if err := s.enableSentry(); err != nil {
return err
}
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go s.serveUntilShutdown()
return nil
},
OnStop: s.cleanShutdown,
})
return s, nil
}
@@ -264,23 +271,25 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
```go
// internal/server/http.go
func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{
Addr: listenAddr,
func (s *Server) newHTTPServer() *http.Server {
return &http.Server{
Addr: fmt.Sprintf(":%d", s.params.Config.Port),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
Handler: s,
}
}
s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", listenAddr)
// serveUntilShutdown returns when the stop hook shuts the HTTP server down.
// If it stops for any other reason, such as its port being taken, it asks fx
// to shut down with exit code 1.
func (s *Server) serveUntilShutdown() {
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.log.Error("listen error", "error", err)
if s.cancelFunc != nil {
s.cancelFunc()
if err := s.params.Shutdowner.Shutdown(fx.ExitCode(1)); err != nil {
s.log.Error("shutdown request failed", "error", err)
}
}
}
@@ -292,43 +301,30 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
## Signal Handling and Graceful Shutdown
fx owns SIGINT, SIGTERM and the exit code. `Run()` in `main` waits for one of
those signals or for a call to `Shutdown()` on `fx.Shutdowner`, runs the stop
hooks, and exits 0 after a signal, or with the code the call gave in
`fx.ExitCode`. It exits 1 instead when a start hook or a stop hook returns an
error; when a start hook fails, fx first runs the stop hooks of everything
already started. No other code calls `signal.Notify` or `os.Exit`: the listen
error above asks fx to shut down with `fx.ExitCode(1)`, and a Sentry start
failure is returned from the start hook. Each component releases its own
resources in its own stop hook, which fx runs in the reverse order of start.
```go
func (s *Server) serve() int {
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
// Signal watcher
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()
for range s.ctx.Done() {
}
s.cleanShutdown()
return s.exitCode
}
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
s.log.Error("server clean shutdown failed", "error", err)
}
if shutdownCancel != nil {
shutdownCancel()
}
s.cleanupForExit()
// cleanShutdown is the server's stop hook. It fails when requests are still
// running after 5 seconds.
func (s *Server) cleanShutdown(ctx context.Context) error {
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, 5*time.Second)
defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown)
if s.sentryEnabled {
sentry.Flush(2 * time.Second)
}
if err != nil {
return fmt.Errorf("http server shutdown: %w", err)
}
return nil
}
```
@@ -1160,11 +1156,11 @@ s.router.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
Sentry is conditionally enabled based on `SENTRY_DSN` environment variable:
```go
func (s *Server) enableSentry() {
func (s *Server) enableSentry() error {
s.sentryEnabled = false
if s.params.Config.SentryDSN == "" {
return
return nil
}
err := sentry.Init(sentry.ClientOptions{
@@ -1172,15 +1168,17 @@ func (s *Server) enableSentry() {
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
})
if err != nil {
s.log.Error("sentry init failure", "error", err)
os.Exit(1)
return
return fmt.Errorf("sentry init failure: %w", err)
}
s.log.Info("sentry error reporting activated")
s.sentryEnabled = true
return nil
}
```
The server's start hook calls `enableSentry()` and returns its error, so a DSN
Sentry rejects stops startup and fx exits 1.
Sentry middleware with repanic (bubbles panics to chi's Recoverer):
```go
@@ -1192,7 +1190,7 @@ if s.sentryEnabled {
}
```
Flush Sentry on shutdown:
Flush Sentry in the server's stop hook, `cleanShutdown()`:
```go
if s.sentryEnabled {