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

merged merged by cmc on 2026-09-03 17:10 UTC · krz/gitbay:serve-shutdown into main

3 files changed, +106 −4

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