Files
kandev/apps/backend/internal/common/subproc/shared_test.go
T
2026-08-21 19:28:21 +01:00

180 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}