From db2216973ec71de57d2eaf0b2e2a3a9e74984b43 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 13 Aug 2026 02:37:14 +0100 Subject: [PATCH] fix(server): gracefully shut down HTTP proxy to drain in-flight requests On signal, main waited on wg for StartHTTPProxy's server.Listen, which never returns because nothing called server.Shutdown(); it timed out after 10s and process-exited, abruptly killing in-flight GraphQL requests. Expose the proxy Fiber app and register an http-proxy Shutdown component so server.Listen returns after Shutdown, letting connections drain. Guard the shared app handle with a mutex (written by the StartHTTPProxy goroutine, read by the shutdown component). Verified with build, vet, full test and full race suite. --- main.go | 4 +++ server.go | 27 ++++++++++++++++++ server_handlers_test.go | 61 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/main.go b/main.go index c362ede..3a4ef3f 100644 --- a/main.go +++ b/main.go @@ -612,6 +612,10 @@ func main() { return nil }) + // Register HTTP proxy so in-flight requests drain gracefully on shutdown + // instead of being cut off when the process exits. + shutdownManager.RegisterComponent("http-proxy", shutdownHTTPProxy) + // Cache shutdown is handled internally by the cache implementation // Start monitoring server diff --git a/server.go b/server.go index c2d3163..2ed6c80 100644 --- a/server.go +++ b/server.go @@ -1,8 +1,10 @@ package main import ( + "context" "fmt" "strconv" + "sync" "time" "github.com/goccy/go-json" @@ -36,6 +38,28 @@ type DependencyStatus struct { } // StartHTTPProxy initializes and starts the HTTP proxy server. +// httpProxyServer holds the live proxy Fiber app so the shutdown sequence can +// call Shutdown() on it, letting in-flight requests drain instead of being +// cut off by process exit. It is written by the StartHTTPProxy goroutine and +// read by the shutdown component, hence guarding with a mutex. +var ( + httpProxyMu sync.RWMutex + httpProxyApp *fiber.App +) + +// shutdownHTTPProxy gracefully stops the HTTP proxy server. It is invoked from +// the ShutdownManager during shutdown so server.Listen returns and the proxy +// can finish in-flight requests. +func shutdownHTTPProxy(_ context.Context) error { + httpProxyMu.RLock() + app := httpProxyApp + httpProxyMu.RUnlock() + if app != nil { + return app.Shutdown() + } + return nil +} + func StartHTTPProxy() error { cfg.Logger.Debug(&libpack_logger.LogMessage{ Message: "Starting the HTTP proxy", @@ -51,6 +75,9 @@ func StartHTTPProxy() error { } server := fiber.New(serverConfig) + httpProxyMu.Lock() + httpProxyApp = server + httpProxyMu.Unlock() server.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, diff --git a/server_handlers_test.go b/server_handlers_test.go index 679359d..a222491 100644 --- a/server_handlers_test.go +++ b/server_handlers_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "net" @@ -599,3 +600,63 @@ func TestStartHTTPProxy_StartsAndShutdown(t *testing.T) { t.Errorf("want 200, got %d", httpResp.StatusCode) } } + +// Test_shutdownHTTPProxy verifies the graceful-shutdown helper: it is a no-op +// when no server is running, and it stops a listening proxy server so that +// server.Listen returns (enabling a clean in-flight-request drain instead of +// process-exit cutoff). +func (suite *Tests) Test_shutdownHTTPProxy() { + // No server running: must be a safe no-op. + httpProxyMu.Lock() + original := httpProxyApp + httpProxyApp = nil + httpProxyMu.Unlock() + defer func() { + httpProxyMu.Lock() + httpProxyApp = original + httpProxyMu.Unlock() + }() + suite.NoError(shutdownHTTPProxy(context.Background())) + + // With a listening server assigned, shutdown must stop it. + app := fiber.New() + app.Get("/ping", func(c fiber.Ctx) error { return c.SendStatus(200) }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + suite.Require().NoError(err) + + listenDone := make(chan struct{}) + go func() { + defer close(listenDone) + _ = app.Listener(ln) + }() + + // Wait until the listener is actually serving (poll the port) before + // shutting down; otherwise Shutdown can race server startup and no-op. + addr := ln.Addr().String() + deadline := time.Now().Add(3 * time.Second) + for { + conn, err := net.Dial("tcp", addr) + if err == nil { + _ = conn.Close() + break + } + if time.Now().After(deadline) { + suite.Fail("proxy server never started listening on " + addr) + return + } + time.Sleep(10 * time.Millisecond) + } + + httpProxyMu.Lock() + httpProxyApp = app + httpProxyMu.Unlock() + suite.NoError(shutdownHTTPProxy(context.Background())) + + select { + case <-listenDone: + // Listen returned because we called Shutdown. + case <-time.After(3 * time.Second): + suite.Fail("proxy server did not shut down; in-flight requests would be cut off") + } +}