Files
kandev/apps/backend/internal/agentctl/server/process/testmain_test.go
T
João Salavisa 6a684211fc fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1249)
* fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1247)

Two independent leaks let opencode acp sessions accumulate indefinitely:

1. instance.Manager never reaped disconnected sessions. StopInstance
   only ran via the kandev DELETE route, so any session whose backend
   went away (UI closed mid-run, WS dropped, opencode failed to emit
   complete) was orphaned forever.
2. Even when DELETE fired, the ACP adapter unconditionally returned
   RequiresProcessKill=false. opencode acp keeps an HTTP server + MCP
   child tree alive after stdin EOF, so closing stdin was a no-op and
   the children re-parented to init.

Fix both:

- Plumb RuntimeConfig.RequiresProcessKill from agents.OpenCodeACP
  through the ExecutorCreateRequest -> CreateInstanceRequest ->
  InstanceConfig -> adapter.Config -> shared.Config chain. acp.Adapter
  now reads cfg.RequiresProcessKill instead of hardcoding false.
- Belt-and-braces: process.Manager.waitForProcessExit timeout fallback
  now killProcessGroup first (only falls back to single-process kill
  if that errors), so any future ACP CLI that ignores stdin EOF still
  gets reaped.
- Add an idle-instance reaper to instance.Manager. Per-instance
  activity middleware tracks inflight HTTP requests + lastActivity;
  a background goroutine sweeps every minute and StopInstance's any
  instance idle past KANDEV_ACP_IDLE_TIMEOUT (default 1h, set to 0
  to disable). Long-lived /agent/stream WS connections keep the
  inflight counter above zero so active sessions are never reaped.
- Validate stdio MCP commands at CreateInstance time via
  exec.LookPath; missing entries are dropped with a warn instead of
  spawning a permanently broken child every session (fixes the
  stale /snap/bin/brave MCP repro in the issue).

Regression tests: process-group kill (parent+child fixture), idle
reaper end-to-end, MCP validation, opencode-acp opt-in vs other
ACP agents' default.

* fix: address PR review feedback on #1247 fix

- idle_reaper: extract reapSingleIdleInstance helper with defer cancel(),
  add reaperStop polling between sweep iterations so Shutdown drains in
  one StopInstance window instead of N * timeout (cubic, claude, greptile).
- instance.Manager: use sync.Once instead of m.mu to gate reaper-stop
  channel close (claude).
- mcpStdioValidationError: tolerate compound Command strings by
  resolving only the first whitespace-delimited token; reject entries
  with neither URL nor Command outright; document agentctl-vs-agent
  PATH skew in Docker/SSH executor modes (claude, greptile, cubic).
- manager_killgroup_test: treat zombie state as dead via /proc/<pid>/stat
  on Linux + bump Eventually window to 15s — CI containers' PID 1 can
  delay harvesting orphans long enough to flake signal(0) probes.
- instance package: add goleak TestMain for the new reaper goroutine
  (coderabbit).
- AGENTS.md: correct SIGKILL vs SIGTERM wording; clarify the timeout
  fallback only fires on ctx.Done (coderabbit, cubic).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-02 21:46:49 +01:00

169 lines
6.3 KiB
Go

package process
import (
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"testing"
"time"
"go.uber.org/goleak"
)
// kandevTestFixtureEnv activates fixture-binary mode in this test binary.
// Tests use fixtureExec / fixtureShellExec (below) to spawn known commands
// (sleep / echo / cat) without depending on Unix-only shell tooling — the
// test binary re-invokes itself with this env var set, and TestMain dispatches
// to runFixture before the normal test runner starts. This replaces the
// previous fixtures that hardcoded `sleep`, `cat`, `echo`, `printf` which
// don't exist as standalone executables on Windows.
const kandevTestFixtureEnv = "KANDEV_TEST_FIXTURE"
// TestMain branches into fixture-binary mode when the activation env var
// is set; otherwise it runs the test suite normally — wrapped in goleak so
// the per-process subprocess managers (workspace tracker, PTY pumps, poll
// loops) surface goroutine leaks here.
func TestMain(m *testing.M) {
if spec := os.Getenv(kandevTestFixtureEnv); spec != "" {
runFixture(spec)
return
}
goleak.VerifyTestMain(m)
}
// runFixture executes a whitespace-separated command spec and exits.
// Supported commands:
//
// sleep <secs> — sleep for <secs> seconds
// echo <args...> — print args joined by spaces, plus newline
// cat — copy stdin to stdout until EOF
// echo-then-sleep <msg> <secs> — print msg, then sleep <secs>
//
// New commands can be added here as tests need them; the goal is to keep the
// surface tiny so the helper stays inspectable.
func runFixture(spec string) {
parts := strings.Fields(spec)
if len(parts) == 0 {
fmt.Fprintln(os.Stderr, "fixture: empty spec")
os.Exit(2)
}
switch parts[0] {
case "sleep":
if len(parts) != 2 {
fmt.Fprintln(os.Stderr, "fixture: sleep takes 1 arg")
os.Exit(2)
}
secs, err := strconv.Atoi(parts[1])
if err != nil {
fmt.Fprintf(os.Stderr, "fixture: sleep: bad seconds %q\n", parts[1])
os.Exit(2)
}
time.Sleep(time.Duration(secs) * time.Second)
case "echo":
fmt.Println(strings.Join(parts[1:], " "))
case "cat":
_, _ = io.Copy(os.Stdout, os.Stdin)
case "echo-then-sleep":
if len(parts) != 3 {
fmt.Fprintln(os.Stderr, "fixture: echo-then-sleep takes 2 args")
os.Exit(2)
}
fmt.Println(parts[1])
// Force the kernel to flush this write to the parent's pipe before
// we sleep. On Windows in particular, an anonymous pipe's first
// small write can sit in the per-process buffer until the writer
// makes a second write or exits — that would defeat the point of a
// "print, then wait" fixture used by output-capture tests.
_ = os.Stdout.Sync()
secs, err := strconv.Atoi(parts[2])
if err != nil {
fmt.Fprintf(os.Stderr, "fixture: echo-then-sleep: bad seconds %q\n", parts[2])
os.Exit(2)
}
time.Sleep(time.Duration(secs) * time.Second)
case "sleep-with-child":
// Forks a child copy of this fixture binary (also sleeping <secs>),
// writes the child PID to <pidfile>, then sleeps itself. Used by the
// process group kill regression test: when the parent's process
// group is reaped, the child must die too. Both processes ignore
// stdin so close-stdin-then-wait can't reach them on its own.
if len(parts) != 3 {
fmt.Fprintln(os.Stderr, "fixture: sleep-with-child takes 2 args")
os.Exit(2)
}
pidFile := parts[1]
secs, err := strconv.Atoi(parts[2])
if err != nil {
fmt.Fprintf(os.Stderr, "fixture: sleep-with-child: bad seconds %q\n", parts[2])
os.Exit(2)
}
childCmd := exec.Command(os.Args[0])
childCmd.Env = append(os.Environ(), kandevTestFixtureEnv+"=sleep "+strconv.Itoa(secs))
if err := childCmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "fixture: sleep-with-child: spawn child: %v\n", err)
os.Exit(2)
}
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(childCmd.Process.Pid)), 0o600); err != nil {
fmt.Fprintf(os.Stderr, "fixture: sleep-with-child: write pidfile: %v\n", err)
os.Exit(2)
}
time.Sleep(time.Duration(secs) * time.Second)
default:
fmt.Fprintf(os.Stderr, "fixture: unknown command %q\n", parts[0])
os.Exit(2)
}
os.Exit(0)
}
// fixtureExec returns the (Command argv, Env) pair tests pass to runners that
// take a Command []string and an Env map (e.g. InteractiveStartRequest). The
// runner spawns this test binary with the env var set, TestMain dispatches
// to runFixture, and the spec is executed in-process.
func fixtureExec(spec string) (command []string, env map[string]string) {
return []string{os.Args[0]}, map[string]string{kandevTestFixtureEnv: spec}
}
// fixtureShellExec returns the (Command string, Env) pair tests pass to
// runners that take a single shell-string Command (e.g. StartProcessRequest,
// which runs via `sh -lc` on Unix and `cmd /c` on Windows). The spec travels
// in the env var so we don't have to worry about per-shell argument quoting.
//
// Quoting is platform-specific because cmd.exe's `/c` parser strips at most
// one outer pair of quotes — pre-quoting the path on Windows turns into
// `cmd /c "\"C:\path\""` after Go's exec escaping, and cmd then fails to
// resolve the executable. We leave Windows paths bare (Go's syscall.EscapeArg
// adds quotes only if needed) and double-quote on Unix where sh splits on
// whitespace.
func fixtureShellExec(spec string) (command string, env map[string]string) {
if runtime.GOOS == "windows" {
return os.Args[0], map[string]string{kandevTestFixtureEnv: spec}
}
return `"` + os.Args[0] + `"`, map[string]string{kandevTestFixtureEnv: spec}
}
// fixtureCmd builds an *exec.Cmd ready to run a fixture spec via this test
// binary. Used by tests that bypass the runner abstractions and call
// exec.Command directly.
func fixtureCmd(spec string) *exec.Cmd {
cmd := exec.Command(os.Args[0])
cmd.Env = fixtureEnvSlice(spec)
return cmd
}
// fixtureEnvSlice returns os.Environ() plus the fixture activation variable,
// formatted as a []string suitable for exec.Cmd.Env / InstanceConfig.AgentEnv.
func fixtureEnvSlice(spec string) []string {
return append(os.Environ(), kandevTestFixtureEnv+"="+spec)
}
// fixtureArgs returns the argv to spawn this test binary in fixture mode.
// Used together with fixtureEnvSlice when populating fields like
// InstanceConfig.AgentArgs.
func fixtureArgs() []string {
return []string{os.Args[0]}
}