diff --git a/internal/httpapi/panel_handlers.go b/internal/httpapi/panel_handlers.go index dfc5eb2..4f7c334 100644 --- a/internal/httpapi/panel_handlers.go +++ b/internal/httpapi/panel_handlers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "time" ) @@ -72,13 +73,26 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second) defer cancel() - url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + // safe-restart rather than restart, and the difference is not politeness. + // + // The plain endpoint does its work while the request is open: it stops the + // container — which kills this process — and the panel, seeing the caller + // vanish, never gets to the starting half. That took production down and left + // it down for four minutes on 18 August, and it cannot be fixed from this side: + // any button that synchronously asks for its own death has the same shape. + // + // safe-restart is scheduled. The panel answers "Restart scheduled" and does the + // work on its own afterwards, so nothing depends on this process still being + // alive to hear the reply. + url := fmt.Sprintf("%s/api/servers/%s/safe-restart", s.cfg.PanelURL, s.cfg.PanelServerID) + body := strings.NewReader(`{"backup_first":false,"target_id":""}`) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { s.internal(w, r, "build panel request", err) return } req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken) + req.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) if err != nil { diff --git a/internal/httpapi/panel_test.go b/internal/httpapi/panel_test.go index fd2bb7a..d28dff2 100644 --- a/internal/httpapi/panel_test.go +++ b/internal/httpapi/panel_test.go @@ -1,6 +1,7 @@ package httpapi import ( + "io" "net/http" "net/http/httptest" "strings" @@ -15,6 +16,11 @@ import ( // request has to end either way, or whatever sits in front answers for it. func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) { panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Drained first. A real server reads what it was sent, and one that does not + // never learns the caller has hung up — httptest.Server.Close then waits for + // a handler that will never return, and the whole binary times out instead + // of the one request. + _, _ = io.Copy(io.Discard, r.Body) <-r.Context().Done() })) defer panel.Close() @@ -56,6 +62,7 @@ func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) { // And a panel that says no must say so in this server's own words, not in a proxy's. func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) { panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusForbidden) })) defer panel.Close()