Files
kandev/apps/backend/internal/common/subproc/throttle.go
T

195 lines
6.9 KiB
Go

// Package subproc provides a cross-package primitive for capping concurrent
// external-binary execs (gh, git, ...).
//
// Why this exists:
//
// On managed corporate macOS hosts (CrowdStrike Falcon + syspolicyd code
// signing), every fork/exec is intercepted, serialized, and validated.
// kandev workloads that fan out subprocesses (PR poller, worktree creation,
// agent lifecycle) can fire bursts of 40+ external execs in a few seconds,
// turning that EDR queue into a multi-second backlog. Other processes on the
// host queue behind us — including kandev's own retries — and the result is
// a system-wide UI freeze plus a wave of 30s-timeout kills inside kandev.
//
// A Throttle caps concurrent execs for one binary. Callers that arrive past
// the cap wait in line, context-aware: a cancelled/deadlined caller exits
// without spawning anything. Each binary owns its own Throttle so a busy
// gh stream cannot starve a worktree-creation git burst (or vice-versa).
package subproc
import (
"context"
"sync"
"time"
)
// Throttle caps concurrent users of a resource via a fixed-size slot pool.
// Zero value is unusable — construct via NewThrottle or NewNamedThrottle.
//
// Throttle is safe for concurrent use. The slot pool can be hot-swapped via
// SetCapForTest so unit tests can drive the semaphore at small caps without
// affecting other tests in the same binary.
//
// When name != "" the Throttle publishes inflight / waiters / acquire / wait
// counters into the package-level expvar maps (see metrics.go) so /debug/vars
// reports queue depth and saturation per pool (gh, git, ...). Unnamed
// throttles stay silent — tests and ad-hoc pools don't pollute /debug/vars.
type Throttle struct {
mu sync.RWMutex
sem chan struct{}
name string
}
// NewThrottle returns a Throttle with the given cap and no name. cap <= 0
// disables throttling (Acquire returns a no-op release immediately).
//
// Production code that wants the throttle's metrics published under
// /debug/vars should use NewNamedThrottle. NewThrottle stays in place for
// tests and for any caller that intentionally wants an unobservable pool.
func NewThrottle(cap int) *Throttle {
return NewNamedThrottle("", cap)
}
// NewNamedThrottle returns a Throttle that publishes inflight / waiters /
// acquire-count / acquire-wait-millis under the package-level expvar maps
// using name as the label key. Use this for process-wide singletons (gh,
// git) so saturation is observable from /debug/vars without a separate
// metrics pipeline.
func NewNamedThrottle(name string, cap int) *Throttle {
t := &Throttle{name: name}
if cap > 0 {
t.sem = make(chan struct{}, cap)
}
t.publishCap(cap)
return t
}
// Acquire blocks until a slot is available or ctx is cancelled. The
// returned release function is safe to call any number of times — only
// the first invocation returns the slot to the pool — but every successful
// Acquire MUST call release at least once (typically via defer) to avoid
// permanently shrinking the pool. On context error it returns ctx.Err()
// and a no-op release so callers can defer unconditionally without
// leaking slots.
//
// Fast path: if ctx is already cancelled at entry, return immediately
// without racing the select against an available slot. Without this,
// Go's select picks randomly between sem<- and ctx.Done() when both are
// ready, so a cancelled caller might still acquire and then fail
// downstream — unobservable but non-deterministic.
func (t *Throttle) Acquire(ctx context.Context) (release func(), err error) {
if err := ctx.Err(); err != nil {
return noopRelease, err
}
sem := t.currentSem()
if sem == nil {
// Throttle disabled (cap<=0) or swapped out by a test teardown.
// Still bump the acquire counter for visibility but don't record
// a wait — there's nothing to queue behind.
t.incAcquire(0)
return noopRelease, nil
}
// Fast path: try to grab a slot without registering as a waiter.
// Most acquires under normal load hit this branch and add zero metric
// overhead. Only the contended path below has to bump the waiter gauge.
select {
case sem <- struct{}{}:
// Re-check ctx after the send: between the pre-check above and
// here, ctx may have been cancelled by another goroutine. Without
// this, a cancelled caller would silently acquire a slot and the
// downstream exec.CommandContext would fail late while holding
// it. Bounce the slot back so the next waiter can use it. Record
// the acquire with 0 wait so cancellation accounting matches the
// slow-path cancel branch below.
if err := ctx.Err(); err != nil {
<-sem
t.incAcquire(0)
return noopRelease, err
}
t.incAcquire(0)
t.incInflight(1)
return t.releaseFunc(sem), nil
default:
}
// Slow path: register as a waiter so /debug/vars reflects current
// queue depth, then block on the select. The deferred dec ensures we
// drop the waiter count whether we acquire, get cancelled, or panic.
t.incWaiters(1)
start := time.Now()
defer t.incWaiters(-1)
select {
case sem <- struct{}{}:
// Same race as the fast path: when both sem<- and ctx.Done() are
// ready, Go's select picks randomly, so a cancelled caller can
// still land in this branch. Re-check and bounce on cancellation.
if err := ctx.Err(); err != nil {
<-sem
t.incAcquire(time.Since(start))
return noopRelease, err
}
t.incAcquire(time.Since(start))
t.incInflight(1)
return t.releaseFunc(sem), nil
case <-ctx.Done():
t.incAcquire(time.Since(start))
return noopRelease, ctx.Err()
}
}
// releaseFunc returns an idempotent release closure that decrements the
// inflight gauge and frees a slot. sync.Once protects against a double
// release (deferred + early-return path in a future refactor) draining a
// slot belonging to another in-flight caller — without it the pool's
// effective cap would slowly leak to zero.
func (t *Throttle) releaseFunc(sem chan struct{}) func() {
var once sync.Once
return func() {
once.Do(func() {
select {
case <-sem:
t.incInflight(-1)
default:
// Pool was swapped out from under us (test teardown).
// Releasing a slot we never held is a no-op.
}
})
}
}
func noopRelease() {}
// currentSem returns the active semaphore under a read lock.
func (t *Throttle) currentSem() chan struct{} {
t.mu.RLock()
defer t.mu.RUnlock()
return t.sem
}
// SetCapForTest replaces the slot pool with one of the given capacity and
// returns a restore function. Test-only — production code MUST NOT call
// this. Use cap=0 to disable throttling for a test.
//
// Restore is idempotent in the sense that the previous pool is captured
// at the call site; nested test setups stack via the returned closure.
func (t *Throttle) SetCapForTest(newCap int) func() {
t.mu.Lock()
prev := t.sem
prevCap := 0
if prev != nil {
prevCap = cap(prev)
}
if newCap <= 0 {
t.sem = nil
} else {
t.sem = make(chan struct{}, newCap)
}
t.mu.Unlock()
t.publishCap(newCap)
return func() {
t.mu.Lock()
t.sem = prev
t.mu.Unlock()
t.publishCap(prevCap)
}
}