6a684211fc
* 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>
206 lines
6.8 KiB
Go
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")
|
|
}
|