Commit 3a7b3219b3

3a7b3219b30badf4216dd113f8b149ebf1568cee

parent: eee4509235

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 16:48 UTC

gitbayd: header and idle timeouts, and a drain on SIGTERM

The HTTP server had no timeouts at all, so a few slow clients holding
headers open could starve the public listener. It now has a 10 s
ReadHeaderTimeout, a 2 min IdleTimeout and a 64 KiB header cap, and the
ACME HTTP-01 listener the same; no write timeout, since archives and
upload-pack stream for as long as they take.

serve blocked on the first listener error and a deploy's restart was a
plain kill. It now traps SIGINT and SIGTERM, closes the ssh and git
listeners, drains the HTTP server and every accepted SSH session (a
push in flight completes) for up to 30 s, and exits 0.

TestServeStopsOnSIGTERM: the daemon exits cleanly within 15 s and the
listener is gone.

Closes #104
Closes #105

Layout: unified · split

cmd/gitbayd/main.go +47 −3
@@ -10,9 +10,11 @@ import (
10 "net" 10 "net"
11 "net/http" 11 "net/http"
12 "os" 12 "os"
13 "os/signal"
13 "path/filepath" 14 "path/filepath"
14 "strconv" 15 "strconv"
15 "strings" 16 "strings"
17 "syscall"
16 "time" 18 "time"
17 19
18 "github.com/spf13/cobra" 20 "github.com/spf13/cobra"
@@ -146,6 +148,11 @@ func serveCmd() *cobra.Command {
146 } 148 }
147 defer stopHookd() 149 defer stopHookd()
148 150
151 // SIGTERM is how a deploy restarts the daemon: stop accepting,
152 // let what is in flight finish, exit 0 (#105).
153 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
154 defer stop()
155
149 // Outbound webhook deliveries. The retry base is overridable 156 // Outbound webhook deliveries. The retry base is overridable
150 // for tests via GITBAY_WEBHOOK_RETRY_BASE. 157 // for tests via GITBAY_WEBHOOK_RETRY_BASE.
151 retryBase := 30 * time.Second 158 retryBase := 30 * time.Second
@@ -173,6 +180,8 @@ func serveCmd() *cobra.Command {
173 }, buildinfo.String()).Run(whCtx) 180 }, buildinfo.String()).Run(whCtx)
174 181
175 errCh := make(chan error, 3) 182 errCh := make(chan error, 3)
183 var sshSrv *sshd.Server
184 var sshLn, gitLn net.Listener
176 if cfg.SSH.Mode == "embedded" { 185 if cfg.SSH.Mode == "embedded" {
177 srv, err := sshd.New(cfg, st) 186 srv, err := sshd.New(cfg, st)
178 if err != nil { 187 if err != nil {
@@ -183,6 +192,7 @@ func serveCmd() *cobra.Command {
183 return err 192 return err
184 } 193 }
185 slog.Info("ssh listening", "addr", ln.Addr()) 194 slog.Info("ssh listening", "addr", ln.Addr())
195 sshSrv, sshLn = srv, ln
186 go func() { errCh <- srv.Serve(ln) }() 196 go func() { errCh <- srv.Serve(ln) }()
187 } else { 197 } else {
188 // system mode: the host sshd owns the SSH port and invokes 198 // system mode: the host sshd owns the SSH port and invokes
@@ -191,7 +201,16 @@ func serveCmd() *cobra.Command {
191 } 201 }
192 202
193 web := httpd.New(cfg, st) 203 web := httpd.New(cfg, st)
194 hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()} 204 // Header and idle timeouts bound what an idle or slow client can
205 // hold open. No write timeout: archives and upload-pack stream
206 // for as long as they take (#104).
207 hs := &http.Server{
208 Addr: cfg.HTTP.Addr,
209 Handler: web.Handler(),
210 ReadHeaderTimeout: 10 * time.Second,
211 IdleTimeout: 2 * time.Minute,
212 MaxHeaderBytes: 64 << 10,
213 }
195 go func() { 214 go func() {
196 slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS) 215 slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
197 switch cfg.HTTP.TLS { 216 switch cfg.HTTP.TLS {
@@ -250,7 +269,9 @@ func serveCmd() *cobra.Command {
250 }) 269 })
251 go func() { 270 go func() {
252 slog.Info("acme http listening", "addr", addr) 271 slog.Info("acme http listening", "addr", addr)
253 if err := http.ListenAndServe(addr, m.HTTPHandler(redirect)); err != nil { 272 acmeHTTP := &http.Server{Addr: addr, Handler: m.HTTPHandler(redirect),
273 ReadHeaderTimeout: 10 * time.Second, IdleTimeout: time.Minute}
274 if err := acmeHTTP.ListenAndServe(); err != nil {
254 slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err) 275 slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
255 } 276 }
256 }() 277 }()
@@ -266,10 +287,33 @@ func serveCmd() *cobra.Command {
266 return err 287 return err
267 } 288 }
268 slog.Info("git-daemon listening", "addr", gln.Addr()) 289 slog.Info("git-daemon listening", "addr", gln.Addr())
290 gitLn = gln
269 go func() { errCh <- gitd.New(cfg, st).Serve(gln) }() 291 go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
270 } 292 }
271 293
272 return <-errCh 294 select {
295 case err := <-errCh:
296 return err
297 case <-ctx.Done():
298 }
299 slog.Info("shutting down")
300 stop()
301 for _, ln := range []net.Listener{sshLn, gitLn} {
302 if ln != nil {
303 ln.Close()
304 }
305 }
306 drain, cancel := context.WithTimeout(context.Background(), 30*time.Second)
307 defer cancel()
308 if err := hs.Shutdown(drain); err != nil {
309 slog.Warn("http shutdown", "err", err)
310 }
311 if sshSrv != nil {
312 if err := sshSrv.Shutdown(drain); err != nil {
313 slog.Warn("ssh shutdown", "err", err)
314 }
315 }
316 return nil
273 }, 317 },
274 } 318 }
275} 319}
e2e/shutdown_test.go added +34
@@ -0,0 +1,34 @@
1package e2e
2
3import (
4 "net/http"
5 "syscall"
6 "testing"
7 "time"
8)
9
10// SIGTERM is how a deploy restarts the daemon. It used to be a plain
11// kill: no listener closed, no request or push allowed to finish. The
12// daemon now stops its listeners, drains, and exits 0 (#105).
13func TestServeStopsOnSIGTERM(t *testing.T) {
14 inst := startInstance(t)
15 if resp, err := http.Get(inst.base() + "/healthz"); err != nil || resp.StatusCode != 200 {
16 t.Fatalf("healthz before shutdown: %v", err)
17 }
18 if err := inst.proc.Process.Signal(syscall.SIGTERM); err != nil {
19 t.Fatal(err)
20 }
21 done := make(chan error, 1)
22 go func() { done <- inst.proc.Wait() }()
23 select {
24 case err := <-done:
25 if err != nil {
26 t.Fatalf("daemon did not exit cleanly on SIGTERM: %v", err)
27 }
28 case <-time.After(15 * time.Second):
29 t.Fatal("daemon still running 15s after SIGTERM")
30 }
31 if _, err := http.Get(inst.base() + "/healthz"); err == nil {
32 t.Fatal("http listener still answering after shutdown")
33 }
34}
internal/sshd/sshd.go +25 −1
@@ -3,6 +3,7 @@
3package sshd 3package sshd
4 4
5import ( 5import (
6 "context"
6 "crypto/ed25519" 7 "crypto/ed25519"
7 "crypto/rand" 8 "crypto/rand"
8 "encoding/base64" 9 "encoding/base64"
@@ -16,6 +17,7 @@ import (
16 "path/filepath" 17 "path/filepath"
17 "strconv" 18 "strconv"
18 "strings" 19 "strings"
20 "sync"
19 "time" 21 "time"
20 22
21 "golang.org/x/crypto/ssh" 23 "golang.org/x/crypto/ssh"
@@ -34,6 +36,7 @@ type Server struct {
34 st *store.Store 36 st *store.Store
35 sshCfg *ssh.ServerConfig 37 sshCfg *ssh.ServerConfig
36 authLimiter *rateLimiter 38 authLimiter *rateLimiter
39 sessions sync.WaitGroup // accepted connections still being served
37} 40}
38 41
39func New(cfg config.Config, st *store.Store) (*Server, error) { 42func New(cfg config.Config, st *store.Store) (*Server, error) {
@@ -139,7 +142,28 @@ func (s *Server) Serve(ln net.Listener) error {
139 if err != nil { 142 if err != nil {
140 return err 143 return err
141 } 144 }
142 go s.handleConn(conn) 145 s.sessions.Add(1)
146 go func() {
147 defer s.sessions.Done()
148 s.handleConn(conn)
149 }()
150 }
151}
152
153// Shutdown waits for every accepted connection to finish, or for ctx. The
154// caller closes the listener first; a push in flight completes rather
155// than being cut mid-pack.
156func (s *Server) Shutdown(ctx context.Context) error {
157 done := make(chan struct{})
158 go func() {
159 s.sessions.Wait()
160 close(done)
161 }()
162 select {
163 case <-done:
164 return nil
165 case <-ctx.Done():
166 return ctx.Err()
143 } 167 }
144} 168}
145 169