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) } }