mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-02 16:20:06 +00:00
Apply Go 1.21+ min/max builtins and improved switch pattern matching across test files and utilities. No functional changes.
267 lines
7.5 KiB
Go
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")
|
|
}
|
|
}
|