package httpd import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "os" "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) // 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 shutdownRequested chan struct{} shutdownOnce sync.Once } // NewWithDeps constructs a Server with API dependencies supplied by the daemon // 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. termMgr may be nil, in which case the /mux terminal surface is not mounted. func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) (*Server, error) { log = loggerOrDefault(log) 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, shutdownRequested: make(chan struct{}), } srv.http = &http.Server{ Handler: NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{ RequestShutdown: srv.requestShutdown, }), // 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.RemoveIfOwned(s.cfg.RunFilePath, info.PID); 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 <-s.shutdownRequested: s.log.Info("shutdown requested over HTTP", "timeout", s.cfg.ShutdownTimeout) 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 } func (s *Server) requestShutdown() { s.shutdownOnce.Do(func() { close(s.shutdownRequested) }) }