diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go index 6ed26c7..3e9cd72 100644 --- a/cmd/cli/main_test.go +++ b/cmd/cli/main_test.go @@ -2,6 +2,7 @@ package cli import ( "os" + "os/exec" "strings" "testing" @@ -13,5 +14,20 @@ var logOutput strings.Builder func TestMain(m *testing.M) { l := zerolog.New(&logOutput) mainLog.Store(&l) + + // Stub the self-upgrade command builder for the whole test binary. The real + // builder execs os.Executable() — which under `go test` IS this test binary + // — with positional args ("upgrade", ...). `go test` stops flag parsing at + // the first positional arg and ignores the rest, so the child just re-runs + // the entire suite, hits the upgrade tests again, and spawns more children: + // a fork bomb of detached processes that stalls the host and (on Windows) + // holds the test binary's image locked, breaking CI artifact cleanup. + // Point it at the test binary with a no-match -test.run so any test that + // reaches performUpgrade still exercises the cmd.Start() success path while + // the child exits immediately without recursing. + newUpgradeCmd = func(exe string) *exec.Cmd { + return exec.Command(exe, "-test.run=^$") + } + os.Exit(m.Run()) } diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 96fd2fe..5c5b275 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -1651,6 +1651,19 @@ func shouldUpgrade(vt string, cv *semver.Version, logger *zerolog.Logger) bool { return true } +// newUpgradeCmd builds the detached command used to self-upgrade. It is a +// package-level variable so tests can stub it. With the real implementation a +// *test* binary would re-exec itself — os.Executable() is the test binary, and +// because `go test` stops flag parsing at the first positional arg ("upgrade") +// it ignores the args and re-runs the entire suite. That child hits the same +// upgrade test and spawns another child, recursively: a fork bomb of detached +// processes that pins the host and locks the test binary's image file. +var newUpgradeCmd = func(exe string) *exec.Cmd { + cmd := exec.Command(exe, "upgrade", "prod", "-vv") + cmd.SysProcAttr = sysProcAttrForDetachedChildProcess() + return cmd +} + // performUpgrade executes the self-upgrade command. // Returns true if upgrade was initiated successfully, false otherwise. func performUpgrade(vt string) bool { @@ -1659,8 +1672,7 @@ func performUpgrade(vt string) bool { mainLog.Load().Error().Err(err).Msg("failed to get executable path, skipped self-upgrade") return false } - cmd := exec.Command(exe, "upgrade", "prod", "-vv") - cmd.SysProcAttr = sysProcAttrForDetachedChildProcess() + cmd := newUpgradeCmd(exe) if err := cmd.Start(); err != nil { mainLog.Load().Error().Err(err).Msg("failed to start self-upgrade") return false diff --git a/cmd/cli/prog_test.go b/cmd/cli/prog_test.go index c4ef5c3..6831f5a 100644 --- a/cmd/cli/prog_test.go +++ b/cmd/cli/prog_test.go @@ -262,6 +262,8 @@ func Test_performUpgrade(t *testing.T) { }, } + // newUpgradeCmd is stubbed in TestMain so performUpgrade does not re-exec + // (and fork-bomb) the test binary; see the comment there. for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) {