test: add async spawn, announcer, dedup, and process scope tests
27 tests covering Phase 4 components: Spawn (10): accepted, concurrency limit, cascade stop, context timeout, parallel fan-out, announcer deliver/drain, pending, back-pressure, concurrent delivery, cleanup Dedup (10): first call, duplicate, different keys, expired entry, check with result, size, concurrent access (100 goroutines), sweep, spawn key deterministic, announce key format Process scope (7): register/owns, deregister, cross-session isolation, list PIDs filters dead, kill all, cleanup, empty session
This commit is contained in:
parent
4a37fbaf7d
commit
290796e5ca
3 changed files with 674 additions and 0 deletions
160
pkg/multiagent/dedup_test.go
Normal file
160
pkg/multiagent/dedup_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDedupCache_FirstCallNotDuplicate(t *testing.T) {
|
||||||
|
dc := NewDedupCache(5 * time.Minute)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
if dc.Check("key-1") {
|
||||||
|
t.Error("first call should not be a duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_SecondCallIsDuplicate(t *testing.T) {
|
||||||
|
dc := NewDedupCache(5 * time.Minute)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
dc.Check("key-1")
|
||||||
|
if !dc.Check("key-1") {
|
||||||
|
t.Error("second call with same key should be a duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_DifferentKeysNotDuplicate(t *testing.T) {
|
||||||
|
dc := NewDedupCache(5 * time.Minute)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
dc.Check("key-1")
|
||||||
|
if dc.Check("key-2") {
|
||||||
|
t.Error("different key should not be a duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_ExpiredEntryNotDuplicate(t *testing.T) {
|
||||||
|
dc := NewDedupCache(50 * time.Millisecond) // very short TTL
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
dc.Check("key-1")
|
||||||
|
time.Sleep(100 * time.Millisecond) // wait for expiry
|
||||||
|
|
||||||
|
if dc.Check("key-1") {
|
||||||
|
t.Error("expired entry should not be treated as duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_CheckWithResult(t *testing.T) {
|
||||||
|
dc := NewDedupCache(5 * time.Minute)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
// First call: not a duplicate
|
||||||
|
result, isDup := dc.CheckWithResult("key-1")
|
||||||
|
if isDup || result != "" {
|
||||||
|
t.Error("first call should not be a duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set result
|
||||||
|
dc.SetResult("key-1", "cached-result")
|
||||||
|
|
||||||
|
// Second call: duplicate with cached result
|
||||||
|
result, isDup = dc.CheckWithResult("key-1")
|
||||||
|
if !isDup {
|
||||||
|
t.Error("second call should be a duplicate")
|
||||||
|
}
|
||||||
|
if result != "cached-result" {
|
||||||
|
t.Errorf("expected cached-result, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_Size(t *testing.T) {
|
||||||
|
dc := NewDedupCache(5 * time.Minute)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
if dc.Size() != 0 {
|
||||||
|
t.Error("expected size 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.Check("key-1")
|
||||||
|
dc.Check("key-2")
|
||||||
|
dc.Check("key-3")
|
||||||
|
|
||||||
|
if dc.Size() != 3 {
|
||||||
|
t.Errorf("expected size 3, got %d", dc.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_ConcurrentAccess(t *testing.T) {
|
||||||
|
dc := NewDedupCache(5 * time.Minute)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
duplicates := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// 100 goroutines all trying the same key
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if dc.Check("same-key") {
|
||||||
|
mu.Lock()
|
||||||
|
duplicates++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Exactly 99 should be duplicates (first one registers)
|
||||||
|
if duplicates != 99 {
|
||||||
|
t.Errorf("expected 99 duplicates, got %d", duplicates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupCache_Sweep(t *testing.T) {
|
||||||
|
dc := NewDedupCache(50 * time.Millisecond)
|
||||||
|
defer dc.Stop()
|
||||||
|
|
||||||
|
dc.Check("key-1")
|
||||||
|
dc.Check("key-2")
|
||||||
|
dc.Check("key-3")
|
||||||
|
|
||||||
|
if dc.Size() != 3 {
|
||||||
|
t.Fatalf("expected 3, got %d", dc.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for entries to expire
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Manually trigger sweep
|
||||||
|
dc.sweep()
|
||||||
|
|
||||||
|
if dc.Size() != 0 {
|
||||||
|
t.Errorf("expected 0 after sweep, got %d", dc.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSpawnKey_Deterministic(t *testing.T) {
|
||||||
|
k1 := BuildSpawnKey("main", "worker", "do X")
|
||||||
|
k2 := BuildSpawnKey("main", "worker", "do X")
|
||||||
|
k3 := BuildSpawnKey("main", "worker", "do Y")
|
||||||
|
|
||||||
|
if k1 != k2 {
|
||||||
|
t.Error("same inputs should produce same key")
|
||||||
|
}
|
||||||
|
if k1 == k3 {
|
||||||
|
t.Error("different task should produce different key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAnnounceKey_Format(t *testing.T) {
|
||||||
|
key := BuildAnnounceKey("child-session", "run-123")
|
||||||
|
expected := "announce:v1:child-session:run-123"
|
||||||
|
if key != expected {
|
||||||
|
t.Errorf("expected %q, got %q", expected, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
378
pkg/multiagent/spawn_test.go
Normal file
378
pkg/multiagent/spawn_test.go
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockAgentResolver provides test agents.
|
||||||
|
type mockAgentResolver struct {
|
||||||
|
agents map[string]*AgentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentResolver) GetAgentInfo(id string) *AgentInfo {
|
||||||
|
return r.agents[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mockAgentResolver) ListAgents() []AgentInfo {
|
||||||
|
var list []AgentInfo
|
||||||
|
for _, a := range r.agents {
|
||||||
|
list = append(list, *a)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockLLMProvider returns a fixed response after a configurable delay.
|
||||||
|
type mockLLMProvider struct {
|
||||||
|
response string
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockLLMProvider) Chat(ctx context.Context, messages []providers.Message, t []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||||
|
if m.delay > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(m.delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &providers.LLMResponse{Content: m.response}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockLLMProvider) GetDefaultModel() string { return "mock" }
|
||||||
|
|
||||||
|
func newTestResolver() *mockAgentResolver {
|
||||||
|
toolReg := tools.NewToolRegistry()
|
||||||
|
return &mockAgentResolver{
|
||||||
|
agents: map[string]*AgentInfo{
|
||||||
|
"worker-a": {
|
||||||
|
ID: "worker-a",
|
||||||
|
Name: "Worker A",
|
||||||
|
Role: "test worker",
|
||||||
|
Provider: &mockLLMProvider{response: "result from A", delay: 50 * time.Millisecond},
|
||||||
|
Tools: toolReg,
|
||||||
|
MaxIter: 3,
|
||||||
|
},
|
||||||
|
"worker-b": {
|
||||||
|
ID: "worker-b",
|
||||||
|
Name: "Worker B",
|
||||||
|
Role: "test worker",
|
||||||
|
Provider: &mockLLMProvider{response: "result from B", delay: 50 * time.Millisecond},
|
||||||
|
Tools: toolReg,
|
||||||
|
MaxIter: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncSpawn_Accepted(t *testing.T) {
|
||||||
|
registry := NewRunRegistry()
|
||||||
|
announcer := NewAnnouncer(10)
|
||||||
|
sm := NewSpawnManager(registry, announcer, 5, 10*time.Second)
|
||||||
|
resolver := newTestResolver()
|
||||||
|
board := NewBlackboard()
|
||||||
|
|
||||||
|
result := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main",
|
||||||
|
ToAgentID: "worker-a",
|
||||||
|
Task: "do something",
|
||||||
|
ParentRunKey: "parent-session",
|
||||||
|
}, "test", "chat1")
|
||||||
|
|
||||||
|
if result.Status != "accepted" {
|
||||||
|
t.Fatalf("expected accepted, got %s: %s", result.Status, result.Error)
|
||||||
|
}
|
||||||
|
if result.RunID == "" {
|
||||||
|
t.Error("expected non-empty RunID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Check announcement was delivered
|
||||||
|
anns := announcer.Drain("parent-session")
|
||||||
|
if len(anns) == 0 {
|
||||||
|
t.Fatal("expected at least 1 announcement")
|
||||||
|
}
|
||||||
|
if anns[0].AgentID != "worker-a" {
|
||||||
|
t.Errorf("expected agent worker-a, got %s", anns[0].AgentID)
|
||||||
|
}
|
||||||
|
if anns[0].Outcome == nil || !anns[0].Outcome.Success {
|
||||||
|
t.Error("expected successful outcome")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncSpawn_ConcurrencyLimit(t *testing.T) {
|
||||||
|
registry := NewRunRegistry()
|
||||||
|
announcer := NewAnnouncer(10)
|
||||||
|
sm := NewSpawnManager(registry, announcer, 2, 10*time.Second) // max 2 concurrent
|
||||||
|
|
||||||
|
slowProvider := &mockLLMProvider{response: "slow result", delay: 500 * time.Millisecond}
|
||||||
|
toolReg := tools.NewToolRegistry()
|
||||||
|
resolver := &mockAgentResolver{
|
||||||
|
agents: map[string]*AgentInfo{
|
||||||
|
"worker": {
|
||||||
|
ID: "worker", Name: "Worker", Provider: slowProvider,
|
||||||
|
Tools: toolReg, MaxIter: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
board := NewBlackboard()
|
||||||
|
|
||||||
|
// Spawn 2 (should succeed — at limit)
|
||||||
|
r1 := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main", ToAgentID: "worker", Task: "task 1", ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
r2 := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main", ToAgentID: "worker", Task: "task 2", ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
|
||||||
|
if r1.Status != "accepted" || r2.Status != "accepted" {
|
||||||
|
t.Fatalf("first 2 should be accepted, got r1=%s r2=%s", r1.Status, r2.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn 3rd (should be rejected — over limit)
|
||||||
|
r3 := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main", ToAgentID: "worker", Task: "task 3", ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
|
||||||
|
if r3.Status != "rejected" {
|
||||||
|
t.Errorf("3rd spawn should be rejected, got %s", r3.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for first 2 to complete
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
|
||||||
|
// Now should be able to spawn again
|
||||||
|
r4 := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main", ToAgentID: "worker", Task: "task 4", ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
|
||||||
|
if r4.Status != "accepted" {
|
||||||
|
t.Errorf("4th spawn should be accepted after slots freed, got %s: %s", r4.Status, r4.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncSpawn_CascadeStop(t *testing.T) {
|
||||||
|
registry := NewRunRegistry()
|
||||||
|
announcer := NewAnnouncer(10)
|
||||||
|
sm := NewSpawnManager(registry, announcer, 5, 10*time.Second)
|
||||||
|
|
||||||
|
slowProvider := &mockLLMProvider{response: "should not complete", delay: 2 * time.Second}
|
||||||
|
toolReg := tools.NewToolRegistry()
|
||||||
|
resolver := &mockAgentResolver{
|
||||||
|
agents: map[string]*AgentInfo{
|
||||||
|
"worker": {
|
||||||
|
ID: "worker", Name: "Worker", Provider: slowProvider,
|
||||||
|
Tools: toolReg, MaxIter: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
board := NewBlackboard()
|
||||||
|
|
||||||
|
r := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main", ToAgentID: "worker", Task: "long task", ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
|
||||||
|
if r.Status != "accepted" {
|
||||||
|
t.Fatalf("expected accepted, got %s", r.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give goroutine time to start
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify it's registered
|
||||||
|
if registry.ActiveCount() == 0 {
|
||||||
|
t.Fatal("expected at least 1 active run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade stop should cancel it
|
||||||
|
killed := registry.CascadeStop(r.SessionKey)
|
||||||
|
if killed == 0 {
|
||||||
|
t.Error("expected cascade stop to kill at least 1 run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for goroutine to clean up
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncSpawn_ContextTimeout(t *testing.T) {
|
||||||
|
registry := NewRunRegistry()
|
||||||
|
announcer := NewAnnouncer(10)
|
||||||
|
sm := NewSpawnManager(registry, announcer, 5, 200*time.Millisecond) // very short timeout
|
||||||
|
|
||||||
|
slowProvider := &mockLLMProvider{response: "too slow", delay: 5 * time.Second}
|
||||||
|
toolReg := tools.NewToolRegistry()
|
||||||
|
resolver := &mockAgentResolver{
|
||||||
|
agents: map[string]*AgentInfo{
|
||||||
|
"worker": {
|
||||||
|
ID: "worker", Name: "Worker", Provider: slowProvider,
|
||||||
|
Tools: toolReg, MaxIter: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
board := NewBlackboard()
|
||||||
|
|
||||||
|
sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main", ToAgentID: "worker", Task: "slow task", ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
|
||||||
|
// Wait for timeout + cleanup
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Should have an announcement with failure
|
||||||
|
anns := announcer.Drain("parent")
|
||||||
|
if len(anns) == 0 {
|
||||||
|
t.Fatal("expected announcement after timeout")
|
||||||
|
}
|
||||||
|
if anns[0].Outcome.Success {
|
||||||
|
t.Error("expected failure after timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncSpawn_ParallelFanOut(t *testing.T) {
|
||||||
|
registry := NewRunRegistry()
|
||||||
|
announcer := NewAnnouncer(20)
|
||||||
|
sm := NewSpawnManager(registry, announcer, 10, 10*time.Second)
|
||||||
|
resolver := newTestResolver()
|
||||||
|
board := NewBlackboard()
|
||||||
|
|
||||||
|
// Fan-out: spawn multiple agents in parallel (Google MapReduce pattern)
|
||||||
|
var results []*SpawnResult
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
target := "worker-a"
|
||||||
|
if i%2 == 1 {
|
||||||
|
target = "worker-b"
|
||||||
|
}
|
||||||
|
r := sm.AsyncSpawn(context.Background(), resolver, board, SpawnRequest{
|
||||||
|
FromAgentID: "main",
|
||||||
|
ToAgentID: target,
|
||||||
|
Task: fmt.Sprintf("parallel task %d", i),
|
||||||
|
ParentRunKey: "parent",
|
||||||
|
}, "test", "chat1")
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All should be accepted
|
||||||
|
for i, r := range results {
|
||||||
|
if r.Status != "accepted" {
|
||||||
|
t.Errorf("spawn %d should be accepted, got %s", i, r.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-in: wait for all to complete and collect results
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
anns := announcer.Drain("parent")
|
||||||
|
if len(anns) != 5 {
|
||||||
|
t.Errorf("expected 5 announcements (fan-in), got %d", len(anns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnnouncer tests
|
||||||
|
|
||||||
|
func TestAnnouncer_DeliverAndDrain(t *testing.T) {
|
||||||
|
a := NewAnnouncer(10)
|
||||||
|
|
||||||
|
a.Deliver("session-1", &Announcement{
|
||||||
|
RunID: "run-1",
|
||||||
|
AgentID: "worker-a",
|
||||||
|
Content: "result 1",
|
||||||
|
})
|
||||||
|
a.Deliver("session-1", &Announcement{
|
||||||
|
RunID: "run-2",
|
||||||
|
AgentID: "worker-b",
|
||||||
|
Content: "result 2",
|
||||||
|
})
|
||||||
|
|
||||||
|
results := a.Drain("session-1")
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("expected 2 announcements, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain again should return empty
|
||||||
|
results2 := a.Drain("session-1")
|
||||||
|
if len(results2) != 0 {
|
||||||
|
t.Errorf("expected 0 after drain, got %d", len(results2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncer_Pending(t *testing.T) {
|
||||||
|
a := NewAnnouncer(10)
|
||||||
|
|
||||||
|
if a.Pending("session-1") != 0 {
|
||||||
|
t.Error("expected 0 pending for new session")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Deliver("session-1", &Announcement{RunID: "r1"})
|
||||||
|
a.Deliver("session-1", &Announcement{RunID: "r2"})
|
||||||
|
|
||||||
|
if a.Pending("session-1") != 2 {
|
||||||
|
t.Errorf("expected 2 pending, got %d", a.Pending("session-1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncer_BackPressure(t *testing.T) {
|
||||||
|
a := NewAnnouncer(2) // tiny buffer
|
||||||
|
|
||||||
|
// Fill buffer
|
||||||
|
a.Deliver("session-1", &Announcement{RunID: "r1", Content: "first"})
|
||||||
|
a.Deliver("session-1", &Announcement{RunID: "r2", Content: "second"})
|
||||||
|
|
||||||
|
// Overflow — should drop oldest
|
||||||
|
a.Deliver("session-1", &Announcement{RunID: "r3", Content: "third"})
|
||||||
|
|
||||||
|
results := a.Drain("session-1")
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("expected 2 after back-pressure, got %d", len(results))
|
||||||
|
}
|
||||||
|
// Most recent should be present
|
||||||
|
hasThird := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.RunID == "r3" {
|
||||||
|
hasThird = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasThird {
|
||||||
|
t.Error("expected the newest announcement (r3) to be present after back-pressure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncer_ConcurrentDelivery(t *testing.T) {
|
||||||
|
a := NewAnnouncer(100)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
a.Deliver("session-1", &Announcement{
|
||||||
|
RunID: fmt.Sprintf("r-%d", n),
|
||||||
|
Content: fmt.Sprintf("result %d", n),
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
results := a.Drain("session-1")
|
||||||
|
if len(results) != 50 {
|
||||||
|
t.Errorf("expected 50 concurrent deliveries, got %d", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncer_Cleanup(t *testing.T) {
|
||||||
|
a := NewAnnouncer(10)
|
||||||
|
a.Deliver("session-1", &Announcement{RunID: "r1"})
|
||||||
|
a.Cleanup("session-1")
|
||||||
|
|
||||||
|
// After cleanup, pending should be 0 (new channel)
|
||||||
|
if a.Pending("session-1") != 0 {
|
||||||
|
t.Error("expected 0 pending after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
136
pkg/tools/process_scope_test.go
Normal file
136
pkg/tools/process_scope_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessScope_RegisterAndOwns(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
ps.Register("session-1", 12345)
|
||||||
|
ps.Register("session-1", 12346)
|
||||||
|
ps.Register("session-2", 99999)
|
||||||
|
|
||||||
|
if !ps.Owns("session-1", 12345) {
|
||||||
|
t.Error("session-1 should own PID 12345")
|
||||||
|
}
|
||||||
|
if !ps.Owns("session-1", 12346) {
|
||||||
|
t.Error("session-1 should own PID 12346")
|
||||||
|
}
|
||||||
|
if ps.Owns("session-1", 99999) {
|
||||||
|
t.Error("session-1 should NOT own PID 99999")
|
||||||
|
}
|
||||||
|
if !ps.Owns("session-2", 99999) {
|
||||||
|
t.Error("session-2 should own PID 99999")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessScope_Deregister(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
ps.Register("session-1", 12345)
|
||||||
|
ps.Deregister("session-1", 12345)
|
||||||
|
|
||||||
|
if ps.Owns("session-1", 12345) {
|
||||||
|
t.Error("should not own after deregister")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessScope_CrossSessionIsolation(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
ps.Register("session-a", 100)
|
||||||
|
ps.Register("session-b", 200)
|
||||||
|
|
||||||
|
if ps.Owns("session-a", 200) {
|
||||||
|
t.Error("session-a should not see session-b's processes")
|
||||||
|
}
|
||||||
|
if ps.Owns("session-b", 100) {
|
||||||
|
t.Error("session-b should not see session-a's processes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessScope_ListPIDs_FiltersDeadProcesses(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
// Register current PID (alive) and a fake PID (dead)
|
||||||
|
ps.Register("session-1", os.Getpid())
|
||||||
|
ps.Register("session-1", 999999999) // almost certainly not a real PID
|
||||||
|
|
||||||
|
live := ps.ListPIDs("session-1")
|
||||||
|
|
||||||
|
// Current process should be in the list
|
||||||
|
found := false
|
||||||
|
for _, pid := range live {
|
||||||
|
if pid == os.Getpid() {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if pid == 999999999 {
|
||||||
|
t.Error("dead PID should have been filtered out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("current process PID should be in live list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessScope_KillAll(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
// Start a real process we can kill
|
||||||
|
cmd := exec.Command("sleep", "60")
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Skipf("cannot start test process: %v", err)
|
||||||
|
}
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
|
||||||
|
ps.Register("session-1", pid)
|
||||||
|
|
||||||
|
killed := ps.KillAll("session-1")
|
||||||
|
if killed != 1 {
|
||||||
|
t.Errorf("expected 1 killed, got %d", killed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reap the child process to prevent zombie (zombie still responds to signal 0).
|
||||||
|
// cmd.Wait() blocks until the process exits and is reaped by the OS.
|
||||||
|
err := cmd.Wait()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected wait to return non-nil error after SIGTERM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After reaping, process should no longer be in the process table
|
||||||
|
if isProcessAlive(pid) {
|
||||||
|
t.Error("process should have been killed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessScope_Cleanup(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
ps.Register("session-1", os.Getpid())
|
||||||
|
ps.Cleanup("session-1")
|
||||||
|
|
||||||
|
if ps.Owns("session-1", os.Getpid()) {
|
||||||
|
t.Error("should not own after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessScope_EmptySession(t *testing.T) {
|
||||||
|
ps := NewProcessScope()
|
||||||
|
|
||||||
|
if ps.Owns("nonexistent", 12345) {
|
||||||
|
t.Error("nonexistent session should not own anything")
|
||||||
|
}
|
||||||
|
|
||||||
|
pids := ps.ListPIDs("nonexistent")
|
||||||
|
if len(pids) != 0 {
|
||||||
|
t.Error("nonexistent session should have no PIDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
killed := ps.KillAll("nonexistent")
|
||||||
|
if killed != 0 {
|
||||||
|
t.Error("killing nonexistent session should kill 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue