Files
kandev/apps/backend/internal/agentctl/server/process/manager_temp_test.go
T

706 lines
24 KiB
Go

package process
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/shell"
"github.com/kandev/kandev/internal/githubauth"
"github.com/kandev/kandev/pkg/agent"
)
func envValue(env []string, key string) string {
prefix := key + "="
for _, item := range env {
if strings.HasPrefix(item, prefix) {
return strings.TrimPrefix(item, prefix)
}
}
return ""
}
func hasEnvValue(env []string, key string) bool {
prefix := key + "="
for _, item := range env {
if strings.HasPrefix(item, prefix) {
return true
}
}
return false
}
func TestManager_BuildFinalCommandPreservesConfiguredTempEnvironment(t *testing.T) {
serviceTemp := setServiceTempTestEnv(t)
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentArgs: []string{"echo"},
AgentEnv: []string{
"PATH=/usr/bin",
"TMPDIR=/configured/tmpdir",
"TMP=/configured/tmp",
"TEMP=/configured/temp",
},
}, newTestLogger(t))
mgr.adapter = newStubAdapter()
if err := mgr.buildFinalCommand(); err != nil {
t.Fatalf("buildFinalCommand() error = %v", err)
}
want := map[string]string{
"TMPDIR": "/configured/tmpdir",
"TMP": "/configured/tmp",
"TEMP": "/configured/temp",
}
for key, value := range want {
if got := envValue(mgr.cmd.Env, key); got != value {
t.Fatalf("%s = %q, want configured service value %q", key, got, value)
}
}
assertNoAgentTempRoot(t, serviceTemp)
}
func TestManager_BuildFinalCommandLeavesUnsetTempEnvironmentUnset(t *testing.T) {
serviceTemp := setServiceTempTestEnv(t)
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentArgs: []string{"echo"},
AgentEnv: []string{"PATH=/usr/bin"},
}, newTestLogger(t))
mgr.adapter = newStubAdapter()
if err := mgr.buildFinalCommand(); err != nil {
t.Fatalf("buildFinalCommand() error = %v", err)
}
for _, key := range []string{"TMPDIR", "TMP", "TEMP"} {
if hasEnvValue(mgr.cmd.Env, key) {
t.Fatalf("%s unexpectedly added to child environment: %q", key, mgr.cmd.Env)
}
}
assertNoAgentTempRoot(t, serviceTemp)
}
func TestManager_BuildFinalCommandPreparesManagedNpmPrefixOutsideAgentHome(t *testing.T) {
home := t.TempDir()
workDir := t.TempDir()
manager := NewManager(&config.InstanceConfig{
WorkDir: workDir,
AgentArgs: []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@scope/managed-acp@1.2.3",
},
AgentEnv: []string{"HOME=" + home},
}, newTestLogger(t))
manager.adapter = newStubAdapter()
if err := manager.buildFinalCommand(); err != nil {
t.Fatalf("buildFinalCommand() error = %v", err)
}
prefix := manager.cmd.Args[4]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
}
info, err := os.Stat(prefix)
if err != nil || !info.IsDir() {
t.Fatalf("managed npm prefix stat = (%v, %v), want an existing directory", info, err)
}
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
t.Fatalf("managed npm prefix was created under agent home, stat error = %v", err)
}
if manager.cmd.Dir != workDir {
t.Fatalf("managed runtime working directory = %q, want workspace %q", manager.cmd.Dir, workDir)
}
}
func TestManager_BuildFinalCommandPreparesOneShotNpmPrefixesWithoutChangingArgs(t *testing.T) {
initialArgs := []string{"npx", "--prefix", managedruntime.NPMProjectPrefix, "pkg@1.2.3", "--initial-only"}
continueArgs := []string{"npx", "--prefix", managedruntime.NPMProjectPrefix, "pkg@1.2.3", "--continue-only"}
manager := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentArgs: []string{"persistent-agent", "--persistent-only"},
}, newTestLogger(t))
manager.adapter = newStubAdapter()
manager.adapterCfg = &adapter.Config{OneShotConfig: &adapter.OneShotConfig{
InitialArgs: initialArgs,
ContinueArgs: continueArgs,
}}
if err := manager.buildFinalCommand(); err != nil {
t.Fatalf("buildFinalCommand() error = %v", err)
}
oneShot := manager.adapterCfg.OneShotConfig
for name, args := range map[string][]string{
"initial": oneShot.InitialArgs,
"continue": oneShot.ContinueArgs,
} {
if len(args) != 5 || args[4] != "--"+name+"-only" {
t.Errorf("one-shot %s args = %#v, want its original command arguments", name, args)
continue
}
prefix := args[2]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Errorf("one-shot %s prefix = %q, want an absolute path under %q", name, prefix, os.TempDir())
}
}
if initialArgs[2] != managedruntime.NPMProjectPrefix || continueArgs[2] != managedruntime.NPMProjectPrefix {
t.Fatalf("preparing one-shot args mutated source command slices: initial=%#v continue=%#v", initialArgs, continueArgs)
}
}
func TestManager_BuildPipedProcessRequestPreparesManagedNpmPrefixOutsideAgentHome(t *testing.T) {
home := t.TempDir()
manager := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentEnv: []string{"HOME=" + home},
}, newTestLogger(t))
args := append(managedruntime.NPMProjectPrefixArgs(), "config", "get", "cache")
req, err := manager.buildPipedProcessRequest(PipedStartRequest{Command: "npm", Args: args})
if err != nil {
t.Fatalf("buildPipedProcessRequest() error = %v", err)
}
prefix := req.Args[1]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
}
if _, err := os.Stat(prefix); err != nil {
t.Fatalf("managed npm prefix was not provisioned: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
t.Fatalf("managed npm prefix was created under agent home, stat error = %v", err)
}
}
func TestManager_BuildFinalCommandFailsSafelyWhenManagedNpmPrefixIsUnavailable(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "not-a-temp-directory")
if err := os.WriteFile(tempFile, []byte("temp"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
if runtime.GOOS == "windows" {
t.Setenv("TEMP", tempFile)
t.Setenv("TMP", tempFile)
} else {
t.Setenv("TMPDIR", tempFile)
}
workDir := t.TempDir()
agentEnv := []string{"HOME=" + t.TempDir(), "KEEP_THIS=unchanged"}
manager := NewManager(&config.InstanceConfig{
WorkDir: workDir,
AgentArgs: []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@scope/managed-acp@1.2.3",
},
AgentEnv: agentEnv,
}, newTestLogger(t))
manager.adapter = newStubAdapter()
err := manager.buildFinalCommand()
if err == nil {
t.Fatal("buildFinalCommand() succeeded without an available npm prefix")
}
if strings.Contains(err.Error(), tempFile) {
t.Fatalf("prefix preparation error exposed temp path: %q", err)
}
if manager.cmd != nil {
t.Fatal("agent command was constructed despite unavailable npm prefix")
}
if got := envValue(manager.cfg.AgentEnv, "KEEP_THIS"); got != "unchanged" {
t.Fatalf("agent environment changed: KEEP_THIS=%q", got)
}
if _, err := os.Stat(filepath.Join(workDir, ".kandev")); !os.IsNotExist(err) {
t.Fatalf("workspace received npm state, stat error = %v", err)
}
}
func TestManager_StartShellInheritsAgentEnvironment(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY-backed shell sessions are unsupported on Windows")
}
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
ShellEnabled: true,
AgentEnv: []string{
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL=http://127.0.0.1:9876",
"PATH=/tmp/kandev-shim:/usr/bin",
},
}, newTestLogger(t))
if err := mgr.StartShell(); err != nil {
t.Fatalf("StartShell() error = %v", err)
}
t.Cleanup(func() { _ = mgr.shell.Stop() })
cfg := mgr.shell.Config()
if got := cfg.Env["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"]; got != "http://127.0.0.1:9876" {
t.Fatalf("shell broker env = %q, want managed broker URL", got)
}
if got := cfg.Env["PATH"]; got != "/tmp/kandev-shim:/usr/bin" {
t.Fatalf("shell PATH = %q, want shim-first PATH", got)
}
}
func TestManager_StartProcessInheritsAgentEnvironment(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{
AgentEnv: []string{
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL=http://127.0.0.1:9876",
"PATH=/tmp/kandev-shim:/usr/bin",
},
}, newTestLogger(t))
req, err := mgr.buildProcessRequest(StartProcessRequest{
SessionID: "session-1",
Command: "echo ok",
Env: map[string]string{
"COMMAND_ONLY": "yes",
"PATH": "/request/path",
},
})
if err != nil {
t.Fatalf("buildProcessRequest() error = %v", err)
}
if got := req.Env["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"]; got != "http://127.0.0.1:9876" {
t.Fatalf("process broker env = %q, want managed broker URL", got)
}
if got := req.Env["PATH"]; got != "/request/path" {
t.Fatalf("process PATH = %q, want explicit request value", got)
}
if got := req.Env["COMMAND_ONLY"]; got != "yes" {
t.Fatalf("process explicit env = %q, want yes", got)
}
}
func TestManager_ProcessEnvironmentMergesIndexedGitConfig(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{
AgentEnv: []string{
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=credential.helper",
"GIT_CONFIG_VALUE_0=!agentctl git-credential",
},
}, newTestLogger(t))
req, err := mgr.buildProcessRequest(StartProcessRequest{
SessionID: "session-1",
Command: "echo ok",
Env: map[string]string{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": "/tmp/hooks",
},
})
if err != nil {
t.Fatalf("buildProcessRequest() error = %v", err)
}
if got := req.Env["GIT_CONFIG_COUNT"]; got != "2" {
t.Fatalf("GIT_CONFIG_COUNT = %q, want merged count 2", got)
}
if got := req.Env["GIT_CONFIG_KEY_1"]; got != "core.hooksPath" {
t.Fatalf("GIT_CONFIG_KEY_1 = %q, want request entry appended", got)
}
}
func TestManagerConfigureReplacesOwnedHostHelperAndPreservesIndexedEnvironment(t *testing.T) {
oldHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/old/gh' auth git-credential \"$@\"; }; f"
newHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/new/gh' auth git-credential \"$@\"; }; f"
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentEnv: []string{
"GIT_CONFIG_COUNT=3",
"GIT_CONFIG_KEY_0=notes.augment.mergeStrategy",
"GIT_CONFIG_VALUE_0=union",
"GIT_CONFIG_KEY_1=core.hooksPath",
"GIT_CONFIG_VALUE_1=/user/hooks",
"GIT_CONFIG_KEY_2=credential.https://github.com.helper",
"GIT_CONFIG_VALUE_2=" + oldHelper,
},
}, newTestLogger(t))
t.Cleanup(mgr.stopWorkspaceTrackers)
configure := func(env map[string]string) {
t.Helper()
if err := mgr.Configure("echo", nil, false, env, "", "", nil, false); err != nil {
t.Fatalf("Configure() error = %v", err)
}
}
configure(map[string]string{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "credential.https://github.com.helper",
"GIT_CONFIG_VALUE_0": newHelper,
})
env := environmentMap(mgr.cfg.AgentEnv)
if env["GIT_CONFIG_COUNT"] != "3" || env["GIT_CONFIG_KEY_0"] != "notes.augment.mergeStrategy" ||
env["GIT_CONFIG_KEY_1"] != "core.hooksPath" || env["GIT_CONFIG_VALUE_1"] != "/user/hooks" ||
env["GIT_CONFIG_KEY_2"] != "credential.https://github.com.helper" || env["GIT_CONFIG_VALUE_2"] != newHelper {
t.Fatalf("configured environment = %#v, want inherited entries plus replacement helper", env)
}
if trackerEnv := environmentMap(mgr.GetWorkspaceTracker().gitCommand(context.Background(), false, "status").Env); trackerEnv["GIT_CONFIG_VALUE_2"] != newHelper {
t.Fatalf("tracker helper = %q, want replacement helper", trackerEnv["GIT_CONFIG_VALUE_2"])
}
configure(nil)
env = environmentMap(mgr.cfg.AgentEnv)
if env["GIT_CONFIG_COUNT"] != "2" || env["GIT_CONFIG_KEY_0"] != "notes.augment.mergeStrategy" ||
env["GIT_CONFIG_KEY_1"] != "core.hooksPath" || env["GIT_CONFIG_VALUE_1"] != "/user/hooks" {
t.Fatalf("reconfigured environment = %#v, want inherited entries without generated helper", env)
}
if _, present := env["GIT_CONFIG_VALUE_2"]; present {
t.Fatalf("stale generated helper remained after reconfiguration: %#v", env)
}
}
func TestManagerConfigureWithEnvironmentReplacesCompleteIndexedBlock(t *testing.T) {
oldHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/old/gh' auth git-credential \"$@\"; }; f"
newHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/new/gh' auth git-credential \"$@\"; }; f"
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentEnv: []string{
"GIT_CONFIG_COUNT=3",
"GIT_CONFIG_KEY_0=notes.augment.mergeStrategy",
"GIT_CONFIG_VALUE_0=union",
"GIT_CONFIG_KEY_1=core.hooksPath",
"GIT_CONFIG_VALUE_1=/user/hooks",
"GIT_CONFIG_KEY_2=credential.https://github.com.helper",
"GIT_CONFIG_VALUE_2=" + oldHelper,
},
}, newTestLogger(t))
t.Cleanup(mgr.stopWorkspaceTrackers)
complete := map[string]string{
"GIT_CONFIG_COUNT": "3",
"GIT_CONFIG_KEY_0": "notes.augment.mergeStrategy",
"GIT_CONFIG_VALUE_0": "union",
"GIT_CONFIG_KEY_1": "core.hooksPath",
"GIT_CONFIG_VALUE_1": "/user/hooks",
"GIT_CONFIG_KEY_2": "credential.https://github.com.helper",
"GIT_CONFIG_VALUE_2": newHelper,
}
if err := mgr.ConfigureWithEnvironment("echo", nil, false, complete, "", "", nil, false); err != nil {
t.Fatalf("ConfigureWithEnvironment() error = %v", err)
}
env := environmentMap(mgr.cfg.AgentEnv)
if env["GIT_CONFIG_COUNT"] != "3" || env["GIT_CONFIG_KEY_0"] != "notes.augment.mergeStrategy" ||
env["GIT_CONFIG_KEY_1"] != "core.hooksPath" || env["GIT_CONFIG_VALUE_1"] != "/user/hooks" ||
env["GIT_CONFIG_KEY_2"] != "credential.https://github.com.helper" || env["GIT_CONFIG_VALUE_2"] != newHelper {
t.Fatalf("complete configured environment = %#v, want one complete replacement block", env)
}
if err := mgr.ConfigureWithEnvironment("echo", nil, false, nil, "", "", nil, false); err != nil {
t.Fatalf("ConfigureWithEnvironment() removal error = %v", err)
}
env = environmentMap(mgr.cfg.AgentEnv)
if len(env["GIT_CONFIG_COUNT"]) != 0 {
t.Fatalf("removed configured environment = %#v, want complete indexed block removed", env)
}
for key := range env {
if strings.HasPrefix(key, "GIT_CONFIG_KEY_") || strings.HasPrefix(key, "GIT_CONFIG_VALUE_") {
t.Fatalf("indexed entry remained after complete removal: %#v", env)
}
}
}
func TestManagerConfigureLeavesConfigurationUnchangedWhenEnvironmentIsInvalid(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentCommand: "old-command",
AgentArgs: []string{"old-command", "--old"},
ApprovalPolicy: "old-policy",
AgentEnv: []string{
"KEEP_ME=yes",
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=core.hooksPath",
"GIT_CONFIG_VALUE_0=/user/hooks",
},
}, newTestLogger(t))
t.Cleanup(mgr.stopWorkspaceTrackers)
err := mgr.ConfigureWithEnvironment(
"new-command", []string{"new-command"}, true,
map[string]string{"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "missing-value"},
"new-policy", "", nil, false,
)
if err == nil {
t.Fatal("ConfigureWithEnvironment() succeeded with malformed indexed Git block")
}
if mgr.cfg.AgentCommand != "old-command" || strings.Join(mgr.cfg.AgentArgs, " ") != "old-command --old" ||
mgr.cfg.ApprovalPolicy != "old-policy" {
t.Fatalf("configuration mutated after failed composition: command=%q args=%#v policy=%q", mgr.cfg.AgentCommand, mgr.cfg.AgentArgs, mgr.cfg.ApprovalPolicy)
}
env := environmentMap(mgr.cfg.AgentEnv)
if env["KEEP_ME"] != "yes" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_VALUE_0"] != "/user/hooks" {
t.Fatalf("environment mutated after failed composition: %#v", env)
}
}
func TestManagerConfigureRemovesObsoleteManagedCredentialEnvironment(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentEnv: []string{
"KEEP_ME=yes",
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL=https://broker.example/resolve",
"KANDEV_GITHUB_CREDENTIAL_LEASE=stale-lease",
"KANDEV_GITHUB_CLI_SHIM_DIR=/stale/shim",
},
}, newTestLogger(t))
t.Cleanup(mgr.stopWorkspaceTrackers)
if err := mgr.Configure("echo", nil, false, nil, "", "", nil, false); err != nil {
t.Fatalf("Configure() error = %v", err)
}
env := environmentMap(mgr.cfg.AgentEnv)
if env["KEEP_ME"] != "yes" {
t.Fatalf("ordinary environment was removed: %#v", env)
}
for _, key := range []string{
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL",
"KANDEV_GITHUB_CREDENTIAL_LEASE",
"KANDEV_GITHUB_CLI_SHIM_DIR",
} {
if _, ok := env[key]; ok {
t.Fatalf("obsolete managed credential %s remained: %#v", key, env)
}
}
}
func TestManager_PipedProcessInheritsAgentEnvironment(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{
AgentEnv: []string{
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL=http://127.0.0.1:9876",
"PATH=/tmp/kandev-shim:/usr/bin",
},
}, newTestLogger(t))
req, err := mgr.buildPipedProcessRequest(PipedStartRequest{
SessionID: "session-1",
Command: "kotlin-language-server",
Env: map[string]string{
"COMMAND_ONLY": "yes",
"PATH": "/request/path",
},
})
if err != nil {
t.Fatalf("buildPipedProcessRequest() error = %v", err)
}
if got := req.Env["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"]; got != "http://127.0.0.1:9876" {
t.Fatalf("piped process broker env = %q, want managed broker URL", got)
}
if got := req.Env["PATH"]; got != "/request/path" {
t.Fatalf("piped process PATH = %q, want explicit request value", got)
}
if got := req.Env["COMMAND_ONLY"]; got != "yes" {
t.Fatalf("piped process explicit env = %q, want yes", got)
}
}
func TestManager_StartAutoShellDoesNotDeadlockOnEnvironmentSnapshot(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{
AgentArgs: []string{"echo"},
ShellEnabled: true,
Protocol: agent.ProtocolACP,
}, newTestLogger(t))
mgr.adapter = newOneShotStubAdapter()
t.Cleanup(func() { _ = mgr.Stop(context.Background()) })
done := make(chan error, 1)
go func() { done <- mgr.Start(context.Background()) }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Start() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Start() deadlocked while creating the auto shell")
}
}
func TestManager_BeginStopWaitsForInFlightAdmission(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
release, err := mgr.admitStart()
if err != nil {
t.Fatalf("admitStart() error = %v", err)
}
stopAdmissionDone := make(chan struct{})
go func() {
mgr.BeginStop()
close(stopAdmissionDone)
}()
select {
case <-stopAdmissionDone:
t.Fatal("BeginStop() returned before in-flight admission completed")
default:
}
release()
<-stopAdmissionDone
if err := mgr.Start(context.Background()); !errors.Is(err, ErrManagerStopping) {
t.Fatalf("Start() error = %v, want manager-stopping error", err)
}
if _, err := mgr.StartProcess(context.Background(), StartProcessRequest{}); !errors.Is(err, ErrManagerStopping) {
t.Fatalf("StartProcess() error = %v, want manager-stopping error", err)
}
if _, err := mgr.StartPipedProcess(PipedStartRequest{}); !errors.Is(err, ErrManagerStopping) {
t.Fatalf("StartPipedProcess() error = %v, want manager-stopping error", err)
}
if err := mgr.StartShell(); !errors.Is(err, ErrManagerStopping) {
t.Fatalf("StartShell() error = %v, want manager-stopping error", err)
}
if err := mgr.StartVscode(context.Background(), "dark"); !errors.Is(err, ErrManagerStopping) {
t.Fatalf("StartVscode() error = %v, want manager-stopping error", err)
}
if _, err := mgr.ShellManager().Start("terminal", shell.DefaultConfig(t.TempDir())); err == nil {
t.Fatal("terminal shell Start() succeeded after BeginStop()")
}
}
func TestManager_StopForTeardownCancelsAndDrainsOwnedOperation(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
allowRelease := make(chan struct{})
var allowReleaseOnce sync.Once
signalRelease := func() {
allowReleaseOnce.Do(func() { close(allowRelease) })
}
operationCtx, release, err := mgr.BeginOwnedOperation(context.Background())
if err != nil {
t.Fatalf("BeginOwnedOperation() error = %v", err)
}
t.Cleanup(func() {
signalRelease()
release()
})
canceled := make(chan struct{})
go func() {
<-operationCtx.Done()
close(canceled)
<-allowRelease
release()
}()
stopDone := make(chan error, 1)
go func() { stopDone <- mgr.StopForTeardown(context.Background()) }()
<-canceled
select {
case err := <-stopDone:
t.Fatalf("StopForTeardown() returned before the owned operation released: %v", err)
default:
}
signalRelease()
if err := <-stopDone; err != nil {
t.Fatalf("StopForTeardown() error = %v", err)
}
if _, _, err := mgr.BeginOwnedOperation(context.Background()); !errors.Is(err, ErrManagerStopping) {
t.Fatalf("BeginOwnedOperation() after teardown error = %v, want %v", err, ErrManagerStopping)
}
}
func TestManager_TeardownAdmissionDrainHonorsContextAndRetries(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
release, err := mgr.admitStart()
if err != nil {
t.Fatalf("admitStart() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = mgr.StopForTeardown(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("StopForTeardown() error = %v, want %v", err, context.Canceled)
}
release()
if err := mgr.StopForTeardown(context.Background()); err != nil {
t.Fatalf("StopForTeardown() retry error = %v", err)
}
}
func TestManager_TeardownWaitsForWorkspaceProcessReap(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
proc := &commandProcess{
info: ProcessInfo{ID: "workspace-blocked-reap"},
stopSignal: make(chan struct{}),
done: make(chan struct{}),
}
mgr.processRunner.processes[proc.info.ID] = proc
stopDone := make(chan error, 1)
go func() { stopDone <- mgr.StopForTeardown(context.Background()) }()
select {
case <-proc.stopSignal:
case err := <-stopDone:
t.Fatalf("StopForTeardown() returned before workspace process stop/reap: %v", err)
}
select {
case err := <-stopDone:
t.Fatalf("StopForTeardown() returned before workspace process reap: %v", err)
default:
}
close(proc.done)
if err := <-stopDone; err != nil {
t.Fatalf("StopForTeardown() error = %v", err)
}
}
func TestManager_TeardownReportsMainProcessGroupReapFailure(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
mgr.cmd = &exec.Cmd{Process: &os.Process{Pid: 424242}}
mgr.status.Store(StatusRunning)
mgr.groupAliveFn = func(int) bool { return true }
mgr.terminateGroupFn = func(int) error { return nil }
mgr.killGroupFn = func(int) error { return nil }
mgr.waitGroupExitFn = func(context.Context, int) bool { return false }
t.Cleanup(func() {
mgr.groupAliveFn = func(int) bool { return false }
mgr.waitGroupExitFn = nil
_ = mgr.StopForTeardown(context.Background())
})
err := mgr.StopForTeardown(context.Background())
if err == nil || !strings.Contains(err.Error(), "remains alive") {
t.Fatalf("StopForTeardown() error = %v, want process-group reap failure", err)
}
}
func TestManager_TeardownReportsMainGoroutineReapFailure(t *testing.T) {
mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
mgr.cmd = &exec.Cmd{Process: &os.Process{Pid: 424243}}
mgr.status.Store(StatusRunning)
mgr.wg.Add(1)
t.Cleanup(func() {
mgr.wg.Done()
mgr.managerWaitFn = nil
_ = mgr.StopForTeardown(context.Background())
})
mgr.groupAliveFn = func(int) bool { return false }
mgr.terminateGroupFn = func(int) error { return nil }
mgr.killGroupFn = func(int) error { return nil }
mgr.managerWaitFn = func(context.Context, <-chan struct{}, time.Duration) bool { return false }
err := mgr.StopForTeardown(context.Background())
if err == nil || !strings.Contains(err.Error(), "goroutines were not reaped") {
t.Fatalf("StopForTeardown() error = %v, want manager reap failure", err)
}
}
func setServiceTempTestEnv(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, key := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(key, root)
}
return root
}
func assertNoAgentTempRoot(t *testing.T, serviceTemp string) {
t.Helper()
if _, err := os.Stat(filepath.Join(serviceTemp, "kandev-agent")); !os.IsNotExist(err) {
t.Fatalf("unexpected kandev-agent root, err = %v", err)
}
}