Commit 9c31ca2462

9c31ca246247177935412c260ab580090955e6e6

parent: 77ff123033

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:57 UTC

packlimit: log refusals once a minute per transport

Ref #262

Layout: unified · split

.gitbay/wiki/Admin.org +3 −1
@@ -218,7 +218,9 @@ push=.
218218 at most =pack_concurrency= − 1 slots when it is above 1, so a signed-in
219219 client (SSH key, bearer token or web session) can always get the last.
220220 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
221 gets 503 with =Retry-After: 30=, git:// an =ERR= line. An SSH client
221 gets 503 with =Retry-After: 30=, git:// an =ERR= line; the daemon
222 logs a =pack limit= warning naming the transport and whether the
223 client was signed in, at most once a minute per transport. An SSH client
222224 that disconnects while queued leaves the queue; an HTTP or git:// one
223225 keeps its place until the wait runs out. A running clone is killed
224226 when its client disconnects, or when no write to the client completes
CHANGELOG.org +3
@@ -240,6 +240,9 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
240240- Anonymous clones are counted per IPv4 address or IPv6 /64, and
241241 together hold at most =pack_concurrency= − 1 slots, so a signed-in
242242 client can always get the last one (#262).
243- A request turned away by the pack limit logs a warning naming the
244 transport and whether the client was signed in, never its address,
245 at most once a minute per transport (#262).
243246- Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take
244247 a pack slot, answer 503 with =Retry-After: 30= when none is free, and
245248 are killed when the client leaves or stops reading (#262).
internal/gitd/gitd.go +3 −1
@@ -72,8 +72,10 @@ func (s *Server) handle(conn net.Conn) {
7272
7373 // A nil done: a queued client that leaves, or a restart, does not
7474 // end the wait; only the limiter's wait does.
75 release, err := s.packs.Acquire(nil, principal(conn.RemoteAddr()))
75 p := principal(conn.RemoteAddr())
76 release, err := s.packs.Acquire(nil, p)
7677 if err != nil {
78 s.packs.Refused("git", p, err)
7779 writeErr(conn, err.Error())
7880 return
7981 }
internal/httpd/smart.go +3 −1
@@ -174,8 +174,10 @@ func (s *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
174174// to it completed for packlimit.StallDeadline — and finish, called once
175175// git has exited, releases the slot.
176176func (s *Server) packSlot(w http.ResponseWriter, r *http.Request) (out io.Writer, kill <-chan struct{}, finish func(), ok bool) {
177 release, err := s.packs.Acquire(s.until(r), s.packPrincipal(r))
177 principal := s.packPrincipal(r)
178 release, err := s.packs.Acquire(s.until(r), principal)
178179 if err != nil {
180 s.packs.Refused("http", principal, err)
179181 msg := "the server is restarting; try again in a minute"
180182 if errors.Is(err, packlimit.ErrBusy) {
181183 msg = "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute"
internal/packlimit/packlimit.go +32 −4
@@ -9,6 +9,7 @@ package packlimit
99
1010import (
1111 "errors"
12 "log/slog"
1213 "net/netip"
1314 "strings"
1415 "sync"
@@ -33,9 +34,10 @@ type Limiter struct {
3334 running int
3435 classHeld int
3536 queued int
36 held map[string]int // running, per principal
37 waiting map[string]int // queued, per principal
38 changed chan struct{} // closed and replaced on every release
37 held map[string]int // running, per principal
38 waiting map[string]int // queued, per principal
39 changed chan struct{} // closed and replaced on every release
40 warned map[string]time.Time // last refusal logged, per transport
3941}
4042
4143// New returns a limiter, or nil — no limit — when max is not positive.
@@ -44,7 +46,33 @@ func New(max, per, queue int, wait time.Duration) *Limiter {
4446 return nil
4547 }
4648 return &Limiter{max: max, per: per, queue: queue, wait: wait,
47 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{})}
49 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{}),
50 warned: map[string]time.Time{}}
51}
52
53// Refused logs that a request on transport was turned away with err, at
54// most once a minute per transport. It names the principal's class
55// (user or ip), never the principal: an address is personal data.
56func (l *Limiter) Refused(transport, principal string, err error) {
57 if l == nil {
58 return
59 }
60 now := time.Now()
61 l.mu.Lock()
62 last, seen := l.warned[transport]
63 if seen && now.Sub(last) < time.Minute {
64 l.mu.Unlock()
65 return
66 }
67 l.warned[transport] = now
68 l.mu.Unlock()
69 class, _, _ := strings.Cut(principal, ":")
70 reason := "busy"
71 if errors.Is(err, ErrGone) {
72 reason = "gone"
73 }
74 slog.Warn("pack limit: request turned away (logged at most once a minute per transport)",
75 "transport", transport, "class", class, "reason", reason)
4876}
4977
5078// CapClass caps the slots that principals starting with prefix may hold
internal/packlimit/packlimit_test.go +31
@@ -1,8 +1,11 @@
11package packlimit
22
33import (
4 "bytes"
45 "errors"
6 "log/slog"
57 "math"
8 "strings"
69 "testing"
710 "time"
811)
@@ -282,3 +285,31 @@ func TestAddrPrincipal(t *testing.T) {
282285 }
283286 }
284287}
288
289// A refusal is logged once a minute per transport, with the principal's
290// class and never its address.
291func TestRefusedLogsOncePerTransport(t *testing.T) {
292 var buf bytes.Buffer
293 old := slog.Default()
294 slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
295 t.Cleanup(func() { slog.SetDefault(old) })
296
297 l := New(1, 0, 0, time.Second)
298 l.Refused("http", "ip:192.0.2.7", ErrBusy)
299 l.Refused("http", "ip:192.0.2.8", ErrBusy)
300 l.Refused("ssh", "user:4", ErrGone)
301 out := buf.String()
302 if n := strings.Count(out, "\n"); n != 2 {
303 t.Fatalf("%d lines, want 2:\n%s", n, out)
304 }
305 for _, want := range []string{"transport=http class=ip reason=busy", "transport=ssh class=user reason=gone"} {
306 if !strings.Contains(out, want) {
307 t.Errorf("missing %q in:\n%s", want, out)
308 }
309 }
310 if strings.Contains(out, "192.0.2") || strings.Contains(out, "user:4") {
311 t.Fatalf("principal logged:\n%s", out)
312 }
313 var none *Limiter
314 none.Refused("git", "ip:x", ErrBusy)
315}
internal/sshd/sshd.go +5 −1
@@ -608,7 +608,11 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s
608608 // Pack generation shares one budget with smart HTTP and git://.
609609 // receive-pack stays outside it: its post-receive runs after the
610610 // client has its report, and must not be queued or killed.
611 release, err := packs.Acquire(done, "user:"+strconv.FormatInt(user.ID, 10))
611 principal := "user:" + strconv.FormatInt(user.ID, 10)
612 release, err := packs.Acquire(done, principal)
613 if err != nil {
614 packs.Refused("ssh", principal, err)
615 }
612616 if errors.Is(err, packlimit.ErrBusy) {
613617 fmt.Fprintln(stderr, "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute")
614618 return protocol.ExitFailure