server: a port that cannot be bound stops startup (closes #266)
check / check (push) Canceled after 0s

The start hook now binds the listen port itself and serves on it in a
goroutine. A bind failure, such as a port already in use or a
privileged port without root, is returned to fx, which stops startup
and exits 1 with an error like "starting http server: listen tcp
:8080: bind: address already in use". Before, the bind happened inside
the goroutine and its error was only logged while the watcher kept
running without the dashboard, API or health check.

The http.Server is now built before that goroutine starts, so Shutdown
no longer reads a field another goroutine may still be writing.

TestPortInUseStopsStartup holds a port and checks that starting the
server on it fails with an error naming it.

Model: opus-5-5
This commit is contained in:
2026-10-13 12:03:04 +00:00
parent 02cb1ec580
commit b9c6d43722
5 changed files with 72 additions and 20 deletions
+21 -9
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"time"
@@ -112,7 +113,7 @@ func New(
}
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
OnStart: func(ctx context.Context) error {
srv.startupTime = time.Now()
err := srv.enableSentry()
@@ -120,9 +121,7 @@ func New(
return err
}
go srv.Run()
return nil
return srv.Run(ctx)
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
@@ -149,8 +148,10 @@ func newHTTPServer(
}
}
// Run starts the HTTP server.
func (s *Server) Run() {
// Run binds the listen port, then serves HTTP on it in a new goroutine
// and returns. A port that cannot be bound is returned as an error, so
// that startup stops instead of running without the HTTP server.
func (s *Server) Run(ctx context.Context) error {
s.SetupRoutes()
listenAddr := fmt.Sprintf(":%d", s.port)
@@ -158,10 +159,21 @@ func (s *Server) Run() {
s.log.Info("http server starting", "addr", listenAddr)
err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("http server error", "error", err)
lc := &net.ListenConfig{}
listener, err := lc.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("starting http server: %w", err)
}
go func() {
err := s.httpServer.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("http server error", "error", err)
}
}()
return nil
}
// Shutdown gracefully shuts down the server, then sends the error