114 lines
3.4 KiB
Go
114 lines
3.4 KiB
Go
package httpd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
|
)
|
|
|
|
// Server is the daemon's HTTP server together with its lifecycle: bind the
|
|
// loopback port, publish the running.json handshake, serve until the context
|
|
// is cancelled, then shut down gracefully and clean up the handshake file.
|
|
type Server struct {
|
|
cfg config.Config
|
|
log *slog.Logger
|
|
http *http.Server
|
|
listen net.Listener
|
|
}
|
|
|
|
// New constructs a Server and binds the listener immediately so a port
|
|
// conflict fails fast — before any running.json is written. The caller owns
|
|
// the returned Server's lifecycle via Run.
|
|
func New(cfg config.Config, log *slog.Logger) (*Server, error) {
|
|
ln, err := net.Listen("tcp", cfg.Addr())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bind %s (is a daemon already running?): %w", cfg.Addr(), err)
|
|
}
|
|
|
|
srv := &Server{
|
|
cfg: cfg,
|
|
log: log,
|
|
listen: ln,
|
|
http: &http.Server{
|
|
Handler: NewRouter(cfg, log),
|
|
// ReadHeaderTimeout guards against slow-loris even on loopback;
|
|
// per-request body/handler timeouts are applied per-surface.
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
},
|
|
}
|
|
return srv, nil
|
|
}
|
|
|
|
// Addr returns the actual bound address (useful when the configured port was 0
|
|
// and the OS chose one — primarily in tests).
|
|
func (s *Server) Addr() net.Addr { return s.listen.Addr() }
|
|
|
|
// Run serves until ctx is cancelled (SIGINT/SIGTERM via signal.NotifyContext),
|
|
// then performs a graceful shutdown bounded by cfg.ShutdownTimeout. It writes
|
|
// running.json before serving and removes it on the way out. Run blocks until
|
|
// shutdown is complete.
|
|
func (s *Server) Run(ctx context.Context) error {
|
|
info := runfile.Info{
|
|
PID: os.Getpid(),
|
|
Port: s.boundPort(),
|
|
StartedAt: time.Now().UTC(),
|
|
}
|
|
if err := runfile.Write(s.cfg.RunFilePath, info); err != nil {
|
|
s.listen.Close()
|
|
return fmt.Errorf("write run-file: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := runfile.Remove(s.cfg.RunFilePath); err != nil {
|
|
s.log.Warn("failed to remove run-file", "path", s.cfg.RunFilePath, "err", err)
|
|
}
|
|
}()
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() {
|
|
s.log.Info("daemon listening", "addr", s.Addr().String(), "pid", info.PID)
|
|
// Serve returns ErrServerClosed on a clean Shutdown; that is success.
|
|
if err := s.http.Serve(s.listen); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
serveErr <- err
|
|
return
|
|
}
|
|
serveErr <- nil
|
|
}()
|
|
|
|
select {
|
|
case err := <-serveErr:
|
|
// Serve died on its own (bind already happened, so this is a real
|
|
// runtime failure) before any shutdown signal.
|
|
return err
|
|
case <-ctx.Done():
|
|
s.log.Info("shutdown signal received, draining connections", "timeout", s.cfg.ShutdownTimeout)
|
|
}
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.ShutdownTimeout)
|
|
defer cancel()
|
|
|
|
if err := s.http.Shutdown(shutdownCtx); err != nil {
|
|
// The deadline elapsed with connections still open; force them closed.
|
|
s.log.Warn("graceful shutdown timed out, forcing close", "err", err)
|
|
_ = s.http.Close()
|
|
return fmt.Errorf("graceful shutdown exceeded %s: %w", s.cfg.ShutdownTimeout, err)
|
|
}
|
|
|
|
s.log.Info("daemon stopped cleanly")
|
|
return <-serveErr
|
|
}
|
|
|
|
func (s *Server) boundPort() int {
|
|
if tcp, ok := s.listen.Addr().(*net.TCPAddr); ok {
|
|
return tcp.Port
|
|
}
|
|
return s.cfg.Port
|
|
}
|