Files
kandev/apps/backend/internal/agentctl/server/process/procattr_windows_test.go
T
2026-07-20 11:04:59 +01:00

187 lines
5.6 KiB
Go

//go:build windows
package process
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func TestWindowsSetProcGroupDoesNotSuspendSharedHelpers(t *testing.T) {
cmd := exec.Command("kandev")
setProcGroup(cmd)
require.NotNil(t, cmd.SysProcAttr)
require.NotZero(t, cmd.SysProcAttr.CreationFlags&syscall.CREATE_NEW_PROCESS_GROUP)
require.Zero(t, cmd.SysProcAttr.CreationFlags&windows.CREATE_SUSPENDED)
}
func TestWindowsSetAgentProcGroupStartsSuspended(t *testing.T) {
cmd := exec.Command("kandev")
setAgentProcGroup(cmd)
require.NotNil(t, cmd.SysProcAttr)
require.NotZero(t, cmd.SysProcAttr.CreationFlags&syscall.CREATE_NEW_PROCESS_GROUP)
require.NotZero(t, cmd.SysProcAttr.CreationFlags&windows.CREATE_SUSPENDED)
}
func TestWindowsSetManagedProcGroupStartsSuspended(t *testing.T) {
cmd := exec.Command("kandev")
setManagedProcGroup(cmd)
require.NotNil(t, cmd.SysProcAttr)
require.NotZero(t, cmd.SysProcAttr.CreationFlags&syscall.CREATE_NEW_PROCESS_GROUP)
require.NotZero(t, cmd.SysProcAttr.CreationFlags&windows.CREATE_SUSPENDED)
}
func TestWindowsProcessLifecycleJobKillsDescendants(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "child.pid")
cmd := fixtureCmd(fmt.Sprintf("delay-then-child %s 200 30", pidFile))
setAgentProcGroup(cmd)
require.NoError(t, cmd.Start())
parentPID := cmd.Process.Pid
waited := false
t.Cleanup(func() {
if waited {
return
}
_ = killProcessGroup(parentPID)
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
lifecycle, err := installProcessLifecycle(cmd)
require.NoError(t, err)
childPID := waitForWindowsChildPID(t, pidFile, 5*time.Second)
releaseProcessLifecycle(lifecycle)
_ = cmd.Wait()
waited = true
require.Eventually(t, func() bool {
return !windowsProcessAlive(childPID)
}, 5*time.Second, 50*time.Millisecond,
"child process %d should be killed when the job handle is released", childPID)
}
func TestWindowsManagedLifecycleReapsDescendantAfterLeaderExit(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "child.pid")
cmd := fixtureCmd(fmt.Sprintf("exit-with-child %s 30", pidFile))
setManagedProcGroup(cmd)
require.NoError(t, cmd.Start())
parentPID := cmd.Process.Pid
waited := false
t.Cleanup(func() {
if waited {
return
}
_ = killProcessGroup(parentPID)
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
lifecycle, err := installProcessLifecycle(cmd)
require.NoError(t, err)
childPID := waitForWindowsChildPID(t, pidFile, 5*time.Second)
require.NoError(t, cmd.Wait())
waited = true
require.True(t, windowsProcessAlive(childPID), "descendant should outlive its leader before job reap")
require.NoError(t, reapProcessLifecycle(lifecycle))
require.Eventually(t, func() bool {
return !windowsProcessAlive(childPID)
}, 5*time.Second, 50*time.Millisecond,
"child process %d should be gone before job reap completes", childPID)
}
func TestWindowsProcessRunnerReapsDescendantAfterLeaderExit(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "runner-child.pid")
command, env := fixtureShellExec(fmt.Sprintf("exit-with-child %s 30", pidFile))
runner := NewProcessRunner(nil, newTestLogger(t), 1024)
info, err := runner.Start(context.Background(), StartProcessRequest{
SessionID: "windows-job-reap",
Kind: types.ProcessKind("test"),
Command: command,
Env: env,
})
require.NoError(t, err)
childPID := waitForWindowsChildPID(t, pidFile, 5*time.Second)
t.Cleanup(func() {
_ = runProcessTaskkill("/F", "/T", "/PID", strconv.Itoa(childPID))
})
require.Eventually(t, func() bool {
_, ok := runner.Get(info.ID, false)
return !ok
}, 5*time.Second, 20*time.Millisecond, "runner should retain the process until job reap completes")
require.False(t, windowsProcessAlive(childPID), "runner returned ownership before its descendant exited")
}
func TestWindowsVscodeLifecycleReapsDescendantAfterLeaderExit(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "vscode-child.pid")
cmd := fixtureCmd(fmt.Sprintf("exit-with-child %s 30", pidFile))
setManagedProcGroup(cmd)
require.NoError(t, cmd.Start())
lifecycle, err := installProcessLifecycle(cmd)
require.NoError(t, err)
childPID := waitForWindowsChildPID(t, pidFile, 5*time.Second)
require.NoError(t, cmd.Wait())
t.Cleanup(func() {
_ = runProcessTaskkill("/F", "/T", "/PID", strconv.Itoa(childPID))
})
vscode := &VscodeManager{logger: newTestLogger(t), lifecycle: lifecycle}
require.NoError(t, vscode.ensureProcessGroupReaped(cmd.Process.Pid))
require.False(t, windowsProcessAlive(childPID), "VS Code returned ownership before its descendant exited")
}
func TestWindowsProcessLifecycleInstallFailsForInvalidPID(t *testing.T) {
_, err := installProcessLifecycle(&exec.Cmd{Process: &os.Process{Pid: -1}})
require.Error(t, err)
}
func waitForWindowsChildPID(t *testing.T, pidFile string, timeout time.Duration) int {
t.Helper()
deadline := time.Now().Add(timeout)
for {
raw, err := os.ReadFile(pidFile)
if err == nil {
pid, perr := strconv.Atoi(strings.TrimSpace(string(raw)))
if perr == nil && pid > 0 {
return pid
}
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for fixture to write child pid to %s", pidFile)
}
time.Sleep(20 * time.Millisecond)
}
}
func windowsProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
output, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/NH").Output()
if err != nil {
return false
}
text := strings.ToLower(string(output))
if strings.Contains(text, "no tasks") {
return false
}
return strings.Contains(text, strconv.Itoa(pid))
}