Files
viettranx 3b4004946e chore: apply go fix syntax upgrades (min/max, switch patterns)
Apply Go 1.21+ min/max builtins and improved switch pattern matching
across test files and utilities. No functional changes.
2026-04-15 11:24:57 +07:00

267 lines
7.5 KiB
Go

package acp
import (
"context"
"encoding/json"
"testing"
"time"
)
// --- ProcessPool construction ---
func TestNewProcessPool_Fields(t *testing.T) {
pp := NewProcessPool("/usr/bin/claude", []string{"--acp"}, "/workspace", 5*time.Minute)
if pp == nil {
t.Fatal("expected non-nil ProcessPool")
}
if pp.agentBinary != "/usr/bin/claude" {
t.Errorf("agentBinary: got %q", pp.agentBinary)
}
if len(pp.agentArgs) != 1 || pp.agentArgs[0] != "--acp" {
t.Errorf("agentArgs: got %v", pp.agentArgs)
}
if pp.workDir != "/workspace" {
t.Errorf("workDir: got %q", pp.workDir)
}
if pp.idleTTL != 5*time.Minute {
t.Errorf("idleTTL: got %v", pp.idleTTL)
}
// Clean up reapLoop goroutine
pp.Close()
}
func TestNewProcessPool_DoneChannelOpen(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
select {
case <-pp.done:
t.Error("done channel should not be closed before Close()")
default:
}
pp.Close()
}
// --- SetToolHandler / getToolHandler ---
func TestProcessPool_SetToolHandler(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
defer pp.Close()
if pp.getToolHandler() != nil {
t.Error("expected nil handler before SetToolHandler")
}
called := make(chan string, 1)
h := RequestHandler(func(ctx context.Context, method string, params json.RawMessage) (any, error) {
called <- method
return nil, nil
})
pp.SetToolHandler(h)
if pp.getToolHandler() == nil {
t.Error("expected non-nil handler after SetToolHandler")
}
}
// --- Close ---
func TestProcessPool_Close_Idempotent(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Close twice must not panic or deadlock
if err := pp.Close(); err != nil {
t.Fatalf("first Close error: %v", err)
}
if err := pp.Close(); err != nil {
t.Fatalf("second Close error: %v", err)
}
}
func TestProcessPool_Close_DrainsDoneChannel(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
pp.Close()
select {
case <-pp.done:
// expected — done closed by Close()
case <-time.After(time.Second):
t.Fatal("expected done channel to be closed after Close()")
}
}
func TestProcessPool_Close_WithFakeProcesses(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Insert fake ACPProcess entries that are already exited
for i := range 3 {
exitedCh := make(chan struct{})
close(exitedCh) // pre-exited
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
}
// Wire a no-op Conn so Close doesn't need real pipes
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store(i, proc)
}
done := make(chan error, 1)
go func() { done <- pp.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Close with pre-exited processes: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Close timed out with pre-exited fake processes")
}
}
func TestProcessPool_Close_TimesOutSlowProcess(t *testing.T) {
// Override the per-process close timeout so the test doesn't burn 5s
// of wall time just to exercise the "fake process never exits" path.
// Production behavior is unchanged — only this test sees 20ms.
saved := processCloseTimeout
processCloseTimeout = 20 * time.Millisecond
t.Cleanup(func() { processCloseTimeout = saved })
pp := NewProcessPool("x", nil, "", time.Minute)
// Insert a fake process that never exits (simulates slow shutdown).
neverExit := make(chan struct{}) // never closed
proc := &ACPProcess{
exited: neverExit,
cancel: func() {},
ctx: context.Background(),
}
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store("slow", proc)
// Close must return promptly even though the fake process never exits,
// because the timeout branch fires after processCloseTimeout elapses.
done := make(chan error, 1)
go func() { done <- pp.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Close returned error: %v", err)
}
case <-time.After(1 * time.Second):
t.Fatal("Close did not return within 1 second even with fast timeout override")
}
}
// --- reapLoop behaviour (via pool state manipulation) ---
func TestProcessPool_ReapLoop_SkipsActivePrompts(t *testing.T) {
pp := NewProcessPool("x", nil, "", 1*time.Millisecond) // very short TTL
// Use a pre-exited channel so Close() doesn't wait 5s
exitedCh := make(chan struct{})
close(exitedCh)
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
lastActive: time.Now().Add(-time.Hour), // very old
}
proc.conn = &Conn{done: make(chan struct{})}
proc.inUse.Store(1) // active prompt — reaper must skip
pp.processes.Store("active-key", proc)
defer pp.Close()
// Wait more than the reap interval (30s internal ticker — can't shorten without
// interface injection). Instead, exercise the Range callback directly by calling
// reapLoop's logic inline as a unit.
//
// The reapLoop uses a 30s ticker, so we exercise the logic directly:
pp.processes.Range(func(key, value any) bool {
p := value.(*ACPProcess)
if p.inUse.Load() > 0 {
return true // should skip
}
t.Errorf("should not reap process with active prompt, key=%v", key)
return true
})
if _, ok := pp.processes.Load("active-key"); !ok {
t.Error("active process should not have been removed")
}
}
func TestProcessPool_GetOrSpawn_ReturnsExisting(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Pre-insert a fake running process — exited channel intentionally never closed
exitedCh := make(chan struct{}) // open = "still running"
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
}
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store("sess-existing", proc)
// Remove the stalling process before Close so cleanup is fast
t.Cleanup(func() {
pp.processes.Delete("sess-existing")
pp.Close()
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, err := pp.GetOrSpawn(ctx, "sess-existing")
if err != nil {
t.Fatalf("GetOrSpawn error: %v", err)
}
if got != proc {
t.Error("expected same *ACPProcess to be returned from pool")
}
}
func TestProcessPool_GetOrSpawn_RespawnsExited(t *testing.T) {
// When stored process has exited channel closed, GetOrSpawn should attempt
// to respawn. The respawn will fail (no real binary) — verify it attempts
// and the stale entry is removed.
pp := NewProcessPool("/nonexistent-binary-xyz", nil, t.TempDir(), time.Minute)
defer pp.Close()
exitedCh := make(chan struct{})
close(exitedCh) // pre-exited = crashed
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
}
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store("sess-crashed", proc)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := pp.GetOrSpawn(ctx, "sess-crashed")
// Expect an error because the binary doesn't exist
if err == nil {
t.Fatal("expected error when respawning with nonexistent binary")
}
// Stale entry should have been removed before the failed spawn
if _, ok := pp.processes.Load("sess-crashed"); ok {
t.Error("expected stale process entry to be removed after crash detection")
}
}
func TestProcessPool_GetOrSpawn_SpawnFailsGracefully(t *testing.T) {
pp := NewProcessPool("/definitely-does-not-exist", nil, t.TempDir(), time.Minute)
defer pp.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := pp.GetOrSpawn(ctx, "new-session")
if err == nil {
t.Fatal("expected error when binary does not exist")
}
}