Files
kandev/apps/backend/internal/agentctl/server/instance/idle_reaper_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

206 lines
6.8 KiB
Go

package instance
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIsIdle_RespectsInflightRequests verifies that an instance with at
// least one in-flight HTTP request is never considered idle, even when
// LastActivity is older than the timeout. This is the WebSocket-stream case
// — a long-lived /agent/stream request keeps the counter above zero so the
// reaper doesn't kill an actively-connected session.
func TestIsIdle_RespectsInflightRequests(t *testing.T) {
inst := &Instance{CreatedAt: time.Now().Add(-1 * time.Hour)}
inst.lastActivityNanos.Store(time.Now().Add(-1 * time.Hour).UnixNano())
inst.inflightRequests.Store(1)
assert.False(t, inst.IsIdle(time.Now(), 5*time.Minute),
"instance with in-flight request must never be idle")
inst.inflightRequests.Store(0)
assert.True(t, inst.IsIdle(time.Now(), 5*time.Minute),
"instance with no in-flight requests and stale activity must be idle")
}
// TestIsIdle_FallsBackToCreatedAt covers the "instance was created but
// never received any HTTP traffic" case — the reaper should still kick in
// after timeout based on CreatedAt.
func TestIsIdle_FallsBackToCreatedAt(t *testing.T) {
inst := &Instance{CreatedAt: time.Now().Add(-2 * time.Hour)}
// lastActivityNanos stays zero.
assert.True(t, inst.IsIdle(time.Now(), 1*time.Hour),
"instance with no activity must fall back to CreatedAt for idle decision")
}
// TestActivityMiddleware_BumpsAndDecrements asserts the middleware
// increments inflightRequests on entry, decrements on exit, and stamps
// lastActivity in both transitions. While the handler is mid-flight the
// counter stays at 1; after it returns the counter is back to 0.
func TestActivityMiddleware_BumpsAndDecrements(t *testing.T) {
inst := &Instance{CreatedAt: time.Now()}
started := make(chan struct{})
release := make(chan struct{})
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(started)
<-release
w.WriteHeader(http.StatusOK)
})
wrapped := activityMiddleware(inst)(handler)
srv := httptest.NewServer(wrapped)
t.Cleanup(srv.Close)
go func() {
resp, err := http.Get(srv.URL + "/")
if err == nil {
_ = resp.Body.Close()
}
}()
<-started
assert.Equal(t, int32(1), inst.inflightRequests.Load(),
"in-flight counter must be 1 while handler is executing")
assert.False(t, inst.LastActivity().IsZero(),
"lastActivity must be stamped on request entry")
close(release)
assert.Eventually(t, func() bool {
return inst.inflightRequests.Load() == 0
}, time.Second, 10*time.Millisecond,
"in-flight counter must drop to 0 after handler returns")
}
// TestManager_IdleReaper_StopsIdleInstance is the end-to-end regression
// test for the disconnected-session leak (GH issue #1247). A configured
// idle timeout + reaper interval well under a second drives the reaper
// fast enough for a unit test; once the instance has been idle past the
// timeout the reaper calls StopInstance and the instance disappears from
// the manager's map.
func TestManager_IdleReaper_StopsIdleInstance(t *testing.T) {
log := newTestLogger(t)
cfg := &config.Config{
Ports: config.PortConfig{Base: 0, Max: 0},
Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP},
IdleTimeout: 50 * time.Millisecond,
IdleReaperInterval: 20 * time.Millisecond,
}
mgr := NewManager(cfg, log)
t.Cleanup(func() {
_ = mgr.Shutdown(context.Background())
})
// Server factory that returns a no-op handler — we don't need a real
// API server to drive the reaper; lastActivity stays at instance
// creation time and the reaper picks it up after IdleTimeout elapses.
mgr.SetServerFactory(func(_ *config.InstanceConfig, _ *process.Manager, _ *logger.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
})
// Allocate a real free port without going through Manager.CreateInstance
// (which needs a workspace path / process manager). Use port 0 via
// allocator by stubbing the manager state instead.
inst := &Instance{
ID: "idle-test",
Port: 0,
Status: "running",
CreatedAt: time.Now().Add(-time.Second), // already older than IdleTimeout
}
mgr.mu.Lock()
mgr.instances[inst.ID] = inst
mgr.mu.Unlock()
require.Eventually(t, func() bool {
mgr.mu.RLock()
_, exists := mgr.instances[inst.ID]
mgr.mu.RUnlock()
return !exists
}, 2*time.Second, 20*time.Millisecond,
"idle reaper should have stopped the instance and removed it from the map")
}
// TestManager_IdleReaper_DisabledByZeroTimeout verifies that setting
// IdleTimeout = 0 disables the reaper entirely — no goroutine should fire,
// no instance should be touched, regardless of how old it is.
func TestManager_IdleReaper_DisabledByZeroTimeout(t *testing.T) {
log := newTestLogger(t)
cfg := &config.Config{
Ports: config.PortConfig{Base: 0, Max: 0},
Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP},
// IdleTimeout zero => disabled.
}
mgr := NewManager(cfg, log)
t.Cleanup(func() {
_ = mgr.Shutdown(context.Background())
})
inst := &Instance{ID: "no-reaper", CreatedAt: time.Now().Add(-24 * time.Hour)}
mgr.mu.Lock()
mgr.instances[inst.ID] = inst
mgr.mu.Unlock()
time.Sleep(100 * time.Millisecond)
mgr.mu.RLock()
_, exists := mgr.instances[inst.ID]
mgr.mu.RUnlock()
assert.True(t, exists, "reaper must be disabled when IdleTimeout=0")
}
// TestActivityMiddleware_Concurrent ensures the inflight counter stays
// consistent under concurrent requests (using atomic ops, no false zero
// while another request is still in flight).
func TestActivityMiddleware_Concurrent(t *testing.T) {
inst := &Instance{CreatedAt: time.Now()}
var seenZero atomic.Int32
gate := make(chan struct{})
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
<-gate
if inst.inflightRequests.Load() == 0 {
seenZero.Add(1)
}
w.WriteHeader(http.StatusOK)
})
wrapped := activityMiddleware(inst)(handler)
srv := httptest.NewServer(wrapped)
t.Cleanup(srv.Close)
for i := 0; i < 5; i++ {
go func() {
resp, err := http.Get(srv.URL + "/")
if err == nil {
_ = resp.Body.Close()
}
}()
}
assert.Eventually(t, func() bool {
return inst.inflightRequests.Load() == 5
}, time.Second, 10*time.Millisecond,
"all five concurrent requests should be counted")
close(gate)
assert.Eventually(t, func() bool {
return inst.inflightRequests.Load() == 0
}, time.Second, 10*time.Millisecond)
assert.Equal(t, int32(0), seenZero.Load(),
"no handler should ever observe a zero inflight counter while another request is mid-flight")
}