180 lines
6.1 KiB
Go
180 lines
6.1 KiB
Go
package subproc
|
||
|
||
import (
|
||
"context"
|
||
"os/exec"
|
||
"runtime"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestResolveCap(t *testing.T) {
|
||
const env = "KANDEV_SUBPROC_CAP_TEST"
|
||
const def = 7
|
||
|
||
cases := []struct {
|
||
name string
|
||
val string
|
||
want int
|
||
}{
|
||
{"empty", "", def},
|
||
{"valid", "3", 3},
|
||
{"zero falls back", "0", def},
|
||
{"negative falls back", "-1", def},
|
||
{"garbage falls back", "abc", def},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Setenv(env, tc.val)
|
||
if got := resolveCap(env, def); got != tc.want {
|
||
t.Errorf("resolveCap(%q, %d) = %d, want %d", tc.val, def, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestResolveGHMaxConcurrent and TestResolveGitMaxConcurrent guard the
|
||
// process-wide defaults: a typo'd env or a fall-through must land on
|
||
// the safe default rather than disable the throttle.
|
||
func TestResolveGHMaxConcurrent(t *testing.T) {
|
||
t.Setenv(ghMaxConcurrentEnv, "")
|
||
if got := resolveGHMaxConcurrent(); got != defaultGHMaxConcurrent {
|
||
t.Errorf("resolveGHMaxConcurrent() = %d, want %d", got, defaultGHMaxConcurrent)
|
||
}
|
||
t.Setenv(ghMaxConcurrentEnv, "garbage")
|
||
if got := resolveGHMaxConcurrent(); got != defaultGHMaxConcurrent {
|
||
t.Errorf("garbage env: got %d, want %d", got, defaultGHMaxConcurrent)
|
||
}
|
||
t.Setenv(ghMaxConcurrentEnv, "5")
|
||
if got := resolveGHMaxConcurrent(); got != 5 {
|
||
t.Errorf("valid env: got %d, want 5", got)
|
||
}
|
||
}
|
||
|
||
func TestResolveGitMaxConcurrent(t *testing.T) {
|
||
t.Setenv(gitMaxConcurrentEnv, "")
|
||
if got := resolveGitMaxConcurrent(); got != defaultGitMaxConcurrent {
|
||
t.Errorf("resolveGitMaxConcurrent() = %d, want %d", got, defaultGitMaxConcurrent)
|
||
}
|
||
t.Setenv(gitMaxConcurrentEnv, "0")
|
||
if got := resolveGitMaxConcurrent(); got != defaultGitMaxConcurrent {
|
||
t.Errorf("zero env: got %d, want %d", got, defaultGitMaxConcurrent)
|
||
}
|
||
t.Setenv(gitMaxConcurrentEnv, "20")
|
||
if got := resolveGitMaxConcurrent(); got != 20 {
|
||
t.Errorf("valid env: got %d, want 20", got)
|
||
}
|
||
}
|
||
|
||
func TestConfigureCapsUsesTypedStartupValues(t *testing.T) {
|
||
previousGH := GH().SetCapForTest(defaultGHMaxConcurrent)
|
||
previousGit := Git().SetCapForTest(defaultGitMaxConcurrent)
|
||
t.Cleanup(func() {
|
||
previousGH()
|
||
previousGit()
|
||
})
|
||
|
||
ConfigureCaps(3, 4)
|
||
if got := GH().Capacity(); got != 3 {
|
||
t.Fatalf("GH capacity = %d, want 3", got)
|
||
}
|
||
if got := GitCapacity(); got != 4 {
|
||
t.Fatalf("Git capacity = %d, want 4", got)
|
||
}
|
||
}
|
||
|
||
// TestGHGitAreDistinctThrottles verifies the two singletons aren't
|
||
// accidentally aliased. A regression where both helpers return the same
|
||
// pool would let a gh storm starve git ops (or vice-versa) — exactly
|
||
// the cross-contention the per-binary split is meant to prevent.
|
||
func TestGHGitAreDistinctThrottles(t *testing.T) {
|
||
if GH() == Git() {
|
||
t.Fatal("GH() and Git() returned the same throttle instance")
|
||
}
|
||
}
|
||
|
||
// TestRunGHCombinedAfterAcquire_ExecTimeoutSurvivesAcquireWait is the
|
||
// regression test for the killed (192×) / context-deadline-exceeded
|
||
// (96×) cascade in the SyncWatchesBatched storm: when a queued waiter
|
||
// finally gets a throttle slot, its per-command exec budget MUST start
|
||
// fresh from that moment, not be eaten by the queue wait.
|
||
//
|
||
// Setup: pin the gh throttle to cap=1 and hold the slot for the
|
||
// acquireWait window. The second caller invokes
|
||
// RunGHCombinedAfterAcquire with a build closure that just records the
|
||
// execCtx deadline relative to the current wallclock. Pre-fix (timer
|
||
// started before Acquire) the recorded deadline would be in the past
|
||
// the moment Acquire returns; post-fix the deadline is acquireWait
|
||
// ahead of the AfterFunc fire, i.e. ~execTimeout ahead of now.
|
||
func TestRunGHCombinedAfterAcquire_ExecTimeoutSurvivesAcquireWait(t *testing.T) {
|
||
if runtime.GOOS == "windows" {
|
||
t.Skip("uses POSIX `true` binary as a no-op subprocess")
|
||
}
|
||
restore := GH().SetCapForTest(1)
|
||
defer restore()
|
||
|
||
ctx := context.Background()
|
||
holdRelease, err := GH().Acquire(ctx)
|
||
if err != nil {
|
||
t.Fatalf("acquire holder: %v", err)
|
||
}
|
||
|
||
// Sized so a pre-fix regression (timer starts BEFORE acquire) yields
|
||
// a NEGATIVE remaining budget at the build callback, while a correct
|
||
// after-acquire timer yields a budget within (~50%, 100%] of
|
||
// execTimeout. The exact fraction depends on scheduler latency
|
||
// between AfterFunc firing and the second goroutine resuming, so the
|
||
// lower bound is generous.
|
||
const acquireWait = 200 * time.Millisecond
|
||
const execTimeout = 500 * time.Millisecond
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(1)
|
||
var (
|
||
gotRemaining time.Duration
|
||
gotRunErr error
|
||
queued = make(chan struct{})
|
||
)
|
||
go func() {
|
||
defer wg.Done()
|
||
// Signal once the goroutine is about to call into the throttle —
|
||
// at that point it WILL be a waiter (cap=1, the slot is held).
|
||
// Pre-fix used time.AfterFunc(acquireWait, ...) to "give the
|
||
// waiter time to queue", which was non-deterministic across CI
|
||
// schedulers and could miss the very regression it's meant to
|
||
// catch.
|
||
close(queued)
|
||
_, gotRunErr, _ = RunGHCombinedAfterAcquire(ctx, execTimeout, func(execCtx context.Context) *exec.Cmd {
|
||
if dl, ok := execCtx.Deadline(); ok {
|
||
gotRemaining = time.Until(dl)
|
||
}
|
||
return exec.CommandContext(execCtx, "true")
|
||
})
|
||
}()
|
||
// Wait for the goroutine to be ready to call Acquire, then yield
|
||
// until the throttle records the queue depth (waiters >= 1). This
|
||
// replaces the prior time.AfterFunc-based barrier, which fired on a
|
||
// scheduler timer rather than on the actual queue state and could
|
||
// miss the regression on slow CI hosts. After that, sleep for
|
||
// acquireWait so the pre-fix regression (timer starts BEFORE
|
||
// Acquire) would have its deadline burned by the wall-clock delay.
|
||
<-queued
|
||
for metricInt(subprocWaiters, "gh") < 1 {
|
||
runtime.Gosched()
|
||
}
|
||
time.Sleep(acquireWait)
|
||
holdRelease()
|
||
wg.Wait()
|
||
|
||
// Pre-fix: gotRemaining would be roughly execTimeout - acquireWait,
|
||
// or negative if acquireWait > execTimeout. Post-fix: gotRemaining
|
||
// should land in (execTimeout/2, execTimeout].
|
||
if gotRemaining <= execTimeout/2 {
|
||
t.Errorf("execCtx deadline was only %v from now (execTimeout=%v); acquire wait burned through the budget", gotRemaining, execTimeout)
|
||
}
|
||
if gotRunErr != nil {
|
||
t.Logf("`true` runErr=%v (informational; deadline budget is the assertion)", gotRunErr)
|
||
}
|
||
}
|