676 lines
19 KiB
Go
676 lines
19 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 waitTimerChange(t *testing.T, mgr *timerManager) {
|
|
t.Helper()
|
|
select {
|
|
case <-mgr.changeEvents():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for timer change signal")
|
|
}
|
|
}
|
|
|
|
func TestNextTimerSidebarLabelChange(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
d time.Duration
|
|
want time.Duration
|
|
}{
|
|
{name: "minutes", d: 2*time.Minute + 10*time.Second, want: 10 * time.Second},
|
|
{name: "minute_to_seconds", d: time.Minute + 500*time.Millisecond, want: 500 * time.Millisecond},
|
|
{name: "seconds", d: 59*time.Second + 500*time.Millisecond, want: 500 * time.Millisecond},
|
|
{name: "subsecond", d: 500 * time.Millisecond, want: timerSidebarSubsecondRefresh},
|
|
{name: "nearly_done", d: 30 * time.Millisecond, want: 30 * time.Millisecond},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := nextTimerSidebarLabelChange(tt.d); got != tt.want {
|
|
t.Fatalf("nextTimerSidebarLabelChange(%s) = %s, want %s", tt.d, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTimerSidebarRefreshAfterUsesSoonestActiveBoundary(t *testing.T) {
|
|
_, mgr, _ := newTestManager(t)
|
|
now := time.Unix(123, 0)
|
|
mgr.mu.Lock()
|
|
mgr.timers["slow"] = &pendingTimer{
|
|
id: "slow",
|
|
status: timerStatusPending,
|
|
firesAt: now.Add(2*time.Minute + 10*time.Second),
|
|
}
|
|
mgr.timers["fast"] = &pendingTimer{
|
|
id: "fast",
|
|
status: timerStatusPending,
|
|
firesAt: now.Add(59*time.Second + 500*time.Millisecond),
|
|
}
|
|
mgr.timers["paused"] = &pendingTimer{
|
|
id: "paused",
|
|
status: timerStatusPaused,
|
|
firesAt: now.Add(100 * time.Millisecond),
|
|
}
|
|
mgr.mu.Unlock()
|
|
|
|
got, ok := mgr.nextSidebarRefreshAfter(now)
|
|
if !ok {
|
|
t.Fatal("nextSidebarRefreshAfter did not find active timers")
|
|
}
|
|
if got != 500*time.Millisecond {
|
|
t.Fatalf("nextSidebarRefreshAfter = %s, want 500ms", got)
|
|
}
|
|
}
|
|
|
|
func TestTimerManagerSignalsChangesForSidebar(t *testing.T) {
|
|
sess, mgr, _ := newTestManager(t)
|
|
owner := fakeChild("p_owner")
|
|
addChild(sess, owner)
|
|
|
|
id, err := mgr.TimerSet("p_owner", "x", "", 60)
|
|
if err != nil {
|
|
t.Fatalf("TimerSet: %v", err)
|
|
}
|
|
waitTimerChange(t, mgr)
|
|
|
|
if err := mgr.TimerPause("p_owner", id); err != nil {
|
|
t.Fatalf("TimerPause: %v", err)
|
|
}
|
|
waitTimerChange(t, mgr)
|
|
|
|
if err := mgr.TimerResume("p_owner", id); err != nil {
|
|
t.Fatalf("TimerResume: %v", err)
|
|
}
|
|
waitTimerChange(t, mgr)
|
|
|
|
if err := mgr.TimerCancel("p_owner", id); err != nil {
|
|
t.Fatalf("TimerCancel: %v", err)
|
|
}
|
|
waitTimerChange(t, mgr)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestTimerCloseChildPrunesWatched covers the happy partial-prune
|
|
// case: an idle_any timer watches two children, one is closed, the
|
|
// timer stays pending and the remaining child can still satisfy it.
|
|
func TestTimerCloseChildPrunesWatched(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.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
|
|
if err != nil {
|
|
t.Fatalf("TimerFireWhenIdleAny: %v", err)
|
|
}
|
|
|
|
mgr.onChildClosed("p_a")
|
|
|
|
mgr.mu.Lock()
|
|
t1, ok := mgr.timers[resp.ID]
|
|
if !ok {
|
|
mgr.mu.Unlock()
|
|
t.Fatalf("timer was removed but still has live watched")
|
|
}
|
|
watched := append([]string(nil), t1.watched...)
|
|
mgr.mu.Unlock()
|
|
if len(watched) != 1 || watched[0] != "p_b" {
|
|
t.Fatalf("watched after close: %v, want [p_b]", watched)
|
|
}
|
|
if got := rec.snapshot(); len(got) != 0 {
|
|
t.Fatalf("close synthesised a fire: %+v", got)
|
|
}
|
|
|
|
// p_b can still satisfy the timer.
|
|
idle := StateIdle
|
|
b.idleState.Store(&idle)
|
|
mgr.onChildStateChanged("p_b", StateIdle)
|
|
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "one done" {
|
|
t.Fatalf("post-prune fire: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestTimerCloseLastWatchedCancels is the regression for the
|
|
// reported stale-fire symptom: the only watched child is closed,
|
|
// so the timer must be cancelled — no synthetic fire, and the
|
|
// registry entry must be gone so a trailing classifier tick for the
|
|
// removed child cannot re-deliver later.
|
|
func TestTimerCloseLastWatchedCancels(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", "stale body", "", []string{"p_a"}, 0)
|
|
if err != nil {
|
|
t.Fatalf("TimerFireWhenIdleAny: %v", err)
|
|
}
|
|
|
|
mgr.onChildClosed("p_a")
|
|
|
|
mgr.mu.Lock()
|
|
_, stillThere := mgr.timers[resp.ID]
|
|
mgr.mu.Unlock()
|
|
if stillThere {
|
|
t.Fatalf("timer with no remaining watched should be removed")
|
|
}
|
|
if got := rec.snapshot(); len(got) != 0 {
|
|
t.Fatalf("close synthesised a fire: %+v", got)
|
|
}
|
|
|
|
// Simulate the trailing classifier tick for the now-closed child —
|
|
// must not fire.
|
|
mgr.onChildStateChanged("p_a", StateIdle)
|
|
if got := rec.snapshot(); len(got) != 0 {
|
|
t.Fatalf("trailing state change re-fired: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestTimerCloseChildIdleAllPartialPrune mirrors the idle_any
|
|
// partial-prune for idle_all: pruning a watched child shrinks the
|
|
// list; the remaining child going idle then satisfies the timer.
|
|
func TestTimerCloseChildIdleAllPartialPrune(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)
|
|
}
|
|
|
|
mgr.onChildClosed("p_a")
|
|
|
|
idle := StateIdle
|
|
b.idleState.Store(&idle)
|
|
mgr.onChildStateChanged("p_b", StateIdle)
|
|
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "all done" {
|
|
t.Fatalf("idle_all after partial prune: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestTimerCloseOwnerCancelsDelay ensures a delay timer is dropped
|
|
// when its owner is closed: no delivery, registry empty, the
|
|
// underlying time.Timer is stopped.
|
|
func TestTimerCloseOwnerCancelsDelay(t *testing.T) {
|
|
sess, mgr, rec := newTestManager(t)
|
|
c := fakeChild("p_owner")
|
|
addChild(sess, c)
|
|
id, err := mgr.TimerSet("p_owner", "x", "", 0.1)
|
|
if err != nil {
|
|
t.Fatalf("TimerSet: %v", err)
|
|
}
|
|
|
|
mgr.onChildClosed("p_owner")
|
|
|
|
mgr.mu.Lock()
|
|
_, stillThere := mgr.timers[id]
|
|
mgr.mu.Unlock()
|
|
if stillThere {
|
|
t.Fatalf("delay timer was not removed when owner closed")
|
|
}
|
|
time.Sleep(200 * time.Millisecond) // past the original firesAt
|
|
if got := rec.snapshot(); len(got) != 0 {
|
|
t.Fatalf("delay timer fired after owner close: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestTimerCloseWatchedSubAgent is the exact shape of the reported
|
|
// stale-fire bug: orchestrator registers a watcher on a sub-agent,
|
|
// the sub-agent is closed, and the orchestrator must receive
|
|
// nothing (no stale body delivered after close_process).
|
|
func TestTimerCloseWatchedSubAgent(t *testing.T) {
|
|
sess, mgr, rec := newTestManager(t)
|
|
parent := fakeChild("p_owner")
|
|
sub := fakeChild("p_sub")
|
|
addChild(sess, parent)
|
|
addChild(sess, sub)
|
|
working := StateWorking
|
|
sub.idleState.Store(&working)
|
|
|
|
if _, err := mgr.TimerFireWhenIdleAny(
|
|
"p_owner",
|
|
"codex-review-591 finished. Read your own pane …",
|
|
"", []string{"p_sub"}, 0,
|
|
); err != nil {
|
|
t.Fatalf("TimerFireWhenIdleAny: %v", err)
|
|
}
|
|
|
|
mgr.onChildClosed("p_sub")
|
|
|
|
// Trailing classifier emission for the closed sub-agent must
|
|
// not deliver anything to the parent.
|
|
mgr.onChildStateChanged("p_sub", StateIdle)
|
|
if got := rec.snapshot(); len(got) != 0 {
|
|
t.Fatalf("stale fire delivered to parent after sub-agent close: %+v", got)
|
|
}
|
|
}
|