From 08a2c3cd96d7cb57113ec2fa77473f21b3a1800f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 19:26:22 +0200 Subject: [PATCH] feat(01-03): cmd/web entrypoint with graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reads DATABASE_URL (required), PORT (default 8080), ENV (default development) from os.Getenv only — no third-party env loader (D-15: .env exported by just dev, prod injects real env) - slog.SetDefault wired before fatal-on-missing-DSN so even startup errors emit structured output; sanitization per T-01-12 (never log DSN) - pgxpool opened via db.NewPool; chi router mounted with ./static as the asset root - http.Server: ReadTimeout=15s, WriteTimeout=15s, IdleTimeout=60s (T-01-10 slow-client mitigation) - signal.NotifyContext (Go 1.21+) traps SIGINT/SIGTERM, propagates through ctx; on signal: srv.Shutdown with 10s timeout, then explicit pool.Close (Pitfall 4 — never via defer) - No /readyz route (Phase 7 scope) --- backend/cmd/web/main.go | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 backend/cmd/web/main.go diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go new file mode 100644 index 0000000..a008e0f --- /dev/null +++ b/backend/cmd/web/main.go @@ -0,0 +1,89 @@ +// Command web is the Phase 1 walking-skeleton HTTP server. It loads env, +// builds a slog handler, opens a pgxpool, mounts the chi router, and serves +// /, /healthz, /demo/time, and /static/* with graceful shutdown on +// SIGINT/SIGTERM (CONTEXT D-19). +// +// No .env parser lives here — `.env` is exported into the process +// environment by `just dev`; production injects real env vars (D-15). +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "backend/internal/db" + "backend/internal/web" +) + +func main() { + env := os.Getenv("ENV") + if env == "" { + env = "development" + } + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + dsn := os.Getenv("DATABASE_URL") + + // Logger first so even fatal-on-missing-DSN paths produce structured + // output. Per Pattern 3: JSON in production, text everywhere else. + slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout))) + + if dsn == "" { + slog.Error("DATABASE_URL is required but unset") + os.Exit(1) + } + + // signal.NotifyContext (Go 1.21+) is the canonical idiom — equivalent + // to signal.Notify + a channel but the resulting ctx propagates the + // cancellation through to handlers, pgxpool dialing, etc. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + pool, err := db.NewPool(ctx, dsn) + if err != nil { + // T-01-12: never log the DSN — only the error type/message. + slog.Error("db connect failed", "err", err) + os.Exit(1) + } + + router := web.NewRouter(pool, "./static") + + srv := &http.Server{ + Addr: ":" + port, + Handler: router, + // T-01-10 slow-client mitigation per RESEARCH Security Domain. + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("listening", "addr", srv.Addr, "env", env) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + slog.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown error", "err", err) + } + + // Pitfall 4: close the pool AFTER Shutdown returns, NOT via defer in + // main — defer ordering is unreliable on fatal-exit paths. + pool.Close() + slog.Info("shutdown complete") +}