Files
patterm/internal/app/timers_test.go

414 lines
12 KiB
Go

package app
import (
"sync"
"testing"
"time"
)
// recorderFire collects timer firings without touching a PTY. Lets the
// timer manager run end-to-end logic in unit tests.
type recorderFire struct {
mu sync.Mutex
fires []recordedFire
}
type recordedFire struct {
OwnerID string
Body string
Label string
}
func (r *recorderFire) fn(owner *Child, body, label string) {
r.mu.Lock()
defer r.mu.Unlock()
id := ""
if owner != nil {
id = owner.ID
}
r.fires = append(r.fires, recordedFire{OwnerID: id, Body: body, Label: label})
}
func (r *recorderFire) snapshot() []recordedFire {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedFire, len(r.fires))
copy(out, r.fires)
return out
}
// fakeChild constructs a Child shell suitable for timer-manager tests.
// Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is
// never reached.
func fakeChild(id string) *Child {
c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "")
running := StatusRunning
c.status.Store(&running)
return c
}
// addChild bypasses Spawn (no PTY needed) so the manager can find the
// child by id and read its IdleState.
func addChild(s *Session, c *Child) {
s.mu.Lock()
s.children[c.ID] = c
s.order = append(s.order, c.ID)
s.mu.Unlock()
}
func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
t.Helper()
sess := NewSession(t.TempDir(), "test")
mgr := newTimerManager(sess)
rec := &recorderFire{}
mgr.fireFn = rec.fn
return sess, mgr, rec
}
func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
if id == "" {
t.Fatal("empty timer id")
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
got := rec.snapshot()
if len(got) != 1 {
t.Fatalf("got %d fires, want 1", len(got))
}
if got[0].Body != "wake up" || got[0].OwnerID != "p_owner" {
t.Fatalf("unexpected fire: %+v", got[0])
}
}
func TestTimerIdleAllAlreadySatisfied(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
idle := StateIdle
a.idleState.Store(&idle)
b.idleState.Store(&idle)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "already_satisfied" {
t.Fatalf("status: got %q want already_satisfied", resp.Status)
}
// fire is dispatched on a goroutine; wait briefly.
time.Sleep(50 * time.Millisecond)
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("fires: %+v", got)
}
}
func TestTimerIdleAnyFiresOnTransition(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
// p_a starts busy.
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
// Flip a into idle and deliver the state-change event.
idle := StateIdle
a.idleState.Store(&idle)
mgr.onChildStateChanged("p_a", StateIdle)
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("fires: %+v", got)
}
}
func TestTimerIdleAnyExcludesBaseline(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
idle := StateIdle
a.idleState.Store(&idle)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
// Send a redundant idle transition for p_a; should NOT fire because
// p_a was idle at registration.
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("unexpected fires: %+v", got)
}
}
func TestTimerCancelPauseResume(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
// Cancel before fire.
id, _ := mgr.TimerSet("p_owner", "x", "", 0.2)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("Cancel: %v", err)
}
time.Sleep(300 * time.Millisecond)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("cancel didn't stop fire: %+v", got)
}
// Pause then resume → fire after resume.
id2, _ := mgr.TimerSet("p_owner", "y", "", 0.2)
time.Sleep(50 * time.Millisecond)
if err := mgr.TimerPause("p_owner", id2); err != nil {
t.Fatalf("Pause: %v", err)
}
time.Sleep(300 * time.Millisecond) // would have fired by now if not paused
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("paused timer fired: %+v", got)
}
if err := mgr.TimerResume("p_owner", id2); err != nil {
t.Fatalf("Resume: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "y" {
t.Fatalf("resume fire: %+v", got)
}
}
func TestTimerOwnershipEnforced(t *testing.T) {
sess, mgr, _ := newTestManager(t)
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, a)
addChild(sess, b)
id, _ := mgr.TimerSet("p_a", "hi", "", 60)
if err := mgr.TimerCancel("p_b", id); err == nil {
t.Fatal("expected ownership error from foreign cancel")
}
if err := mgr.TimerPause("p_b", id); err == nil {
t.Fatal("expected ownership error from foreign pause")
}
}
// TestTimerResumeRechecksIdleAll covers the case where every watched
// child becomes idle while an idle_all timer is paused. Without a resume
// re-check, the timer would stay pending forever because the state
// transitions happened during the pause window.
func TestTimerResumeRechecksIdleAll(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
// Both watched children become idle WHILE THE TIMER IS PAUSED, so
// onChildStateChanged is not consulted for this timer.
idle := StateIdle
a.idleState.Store(&idle)
b.idleState.Store(&idle)
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("expected fire on resume, got: %+v", got)
}
}
// TestTimerResumeRechecksIdleAny covers the same missed-transition shape
// for idle_any: a non-baseline watched child going idle during pause must
// fire on resume.
func TestTimerResumeRechecksIdleAny(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
idle := StateIdle
a.idleState.Store(&idle)
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("expected fire on resume, got: %+v", got)
}
}
// TestTimerResumeIdleAnyExcludesBaselineDuringPause guards against a
// resume re-check firing for a watcher that was idle at registration
// (and therefore part of the baseline) — only non-baseline transitions
// should satisfy idle_any.
func TestTimerResumeIdleAnyExcludesBaselineDuringPause(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
idle := StateIdle
working := StateWorking
a.idleState.Store(&idle) // baseline: already idle
b.idleState.Store(&working) // not baseline
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
// b stays working — only a is idle, and a was baseline. Resume
// must not fire.
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("unexpected fire on resume: %+v", got)
}
}
// TestTimerRecordsRemovedOnFire ensures fired delay timers don't leak
// in the timer registry — bodies and metadata must be released.
func TestTimerRecordsRemovedOnFire(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(rec.snapshot()) != 1 {
t.Fatalf("timer didn't fire")
}
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
count := len(mgr.timers)
mgr.mu.Unlock()
if stillThere {
t.Fatalf("fired timer %s was not removed from registry", id)
}
if count != 0 {
t.Fatalf("timer registry not drained: %d entries", count)
}
}
// TestTimerRecordsRemovedOnCancel ensures canceled timers are dropped
// from the registry.
func TestTimerRecordsRemovedOnCancel(t *testing.T) {
sess, mgr, _ := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("Cancel: %v", err)
}
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("canceled timer %s was not removed from registry", id)
}
}
// TestTimerRecordsRemovedOnIdleFire ensures idle_* timers are dropped
// from the registry once they fire via onChildStateChanged.
func TestTimerRecordsRemovedOnIdleFire(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
idle := StateIdle
a.idleState.Store(&idle)
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 1 {
t.Fatalf("expected fire, got: %+v", got)
}
mgr.mu.Lock()
_, stillThere := mgr.timers[resp.ID]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("fired idle timer %s was not removed from registry", resp.ID)
}
}