Fix idle timer review issues

This commit is contained in:
2026-05-15 11:18:03 +01:00
parent 2b9e1ed77c
commit 543c7cc59a
9 changed files with 417 additions and 35 deletions

View File

@@ -108,6 +108,25 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
renders the canonical `--flag` form. renders the canonical `--flag` form.
### Fixed ### Fixed
- `whoami` and `help("timers")` now advertise the full Solo-parity timer
surface (`timer_set`, `timer_fire_when_idle_any`,
`timer_fire_when_idle_all`, `timer_cancel`, `timer_pause`,
`timer_resume`, `timer_list`) so agents using either tool for
orientation discover them — previously only `timer_wait` was listed.
- Resuming a paused idle-aware timer now re-checks the satisfaction
condition. Previously, if every watched process became idle (or, for
`idle_any`, any non-baseline watcher went idle) while the timer was
paused, the timer stayed pending forever because no further state
transitions were observed.
- Fired and canceled timers are now removed from the timer registry,
so long-running patterm sessions no longer accumulate completed
timer records and message bodies. `timer_list` and the sidebar
indicator already filtered them out; only the in-memory leak is
fixed.
- Per-preset idle-detection config is now installed through `SpawnSpec`
before the child is published to the session, closing a race in
which the classifier goroutine could observe a freshly spawned
process before its preset's classifier strategy was attached.
- Opening the command palette while a scratchpad was focused left the - Opening the command palette while a scratchpad was focused left the
palette wedged — typing did nothing and Esc left the palette's top palette wedged — typing did nothing and Esc left the palette's top
border drawn over the pad until you closed the pad with Ctrl-W and border drawn over the pad until you closed the pad with Ctrl-W and

11
TODO.md
View File

@@ -1,3 +1,14 @@
- [ ] Codex seemed to think that it needed to launch patterm itself to get the mcp working
- [ ] I cant click and drag to select text from codex
- [ ] codex uses perl to interact with the socket rather than calling mcp tools
- when it _did_ open a sub claude it opened it as a separate tab rather than a sub-agent.
- [ ] codex rendering is VERY slow
- maybe we need to use diffing rather than rendering the entire viewport for performance
- We should add a --debug and --profile flag, so we can get detailed performance data and full logs of the agent output to be debugged later on.
- I don't mind what format this is in, ideally easy for LLMs to understand
- [ ] Resuming a long claude session takes a couple of seconds for the entire buffer to load in, it looks like it's scrolling down for a couple seconds.
- In raw alacritty this is instant, so there's some sort of performance issue with patterm's terminal emulation.
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD] - [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints - Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is

View File

@@ -92,9 +92,9 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
// rather than make it implement the full surface. // rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager } type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {} func (a timerListenerAdapter) OnChildSpawned(*Child) {}
func (a timerListenerAdapter) OnChildExited(*Child) {} func (a timerListenerAdapter) OnChildExited(*Child) {}
func (a timerListenerAdapter) OnPTYOut(string, []byte) {} func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) { func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st) a.m.onChildStateChanged(id, st)
} }
@@ -1084,7 +1084,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"list_processes", "get_process_status", "get_project_status", "list_processes", "get_process_status", "get_project_status",
"get_process_output", "get_process_raw_output", "search_output", "get_process_output", "get_process_raw_output", "search_output",
"wait_for_pattern", "get_process_ports", "wait_for_pattern", "get_process_ports",
"send_input", "send_message", "request_human_attention", "timer_wait", "send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
"whoami", "help", "whoami", "help",
} }
@@ -1114,8 +1116,8 @@ func helpFor(topic string) mcp.HelpResponse {
} }
case "lifecycle": case "lifecycle":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "lifecycle", Topic: "lifecycle",
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.", Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"}, RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"},
} }
case "inspection": case "inspection":
@@ -1144,9 +1146,18 @@ func helpFor(topic string) mcp.HelpResponse {
} }
case "timers": case "timers":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "timers", Topic: "timers",
Content: "timer_wait returns a timer_id immediately and injects `[system] Your timer [<label>] has completed.` into your pane when it fires. Use it instead of sleeping in your own process.", Content: "Timers fire by injecting your chosen body (or a default `[system] Your timer [] has completed.` line) back into your pane as a fresh user turn. Use them instead of sleeping in your own process. " +
RelatedTools: []string{"timer_wait"}, "timer_wait / timer_set schedule a delay timer (timer_set lets you set body+label). " +
"timer_fire_when_idle_any fires when any watched process becomes idle (already-idle watchers are excluded from the baseline). " +
"timer_fire_when_idle_all fires when every watched process is idle; if all are idle at registration the response is already_satisfied with no pending timer. " +
"timer_cancel / timer_pause / timer_resume manage outstanding timers; resume re-checks idle conditions in case a watcher went idle while paused. " +
"timer_list shows your pending and paused timers.",
RelatedTools: []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
},
} }
case "readiness": case "readiness":
return mcp.HelpResponse{ return mcp.HelpResponse{

View File

@@ -3,6 +3,8 @@ package app
import ( import (
"strings" "strings"
"testing" "testing"
"github.com/hjbdev/patterm/internal/mcp"
) )
// mkChild builds a Child without starting a PTY. Use sparingly — the // mkChild builds a Child without starting a PTY. Use sparingly — the
@@ -164,6 +166,47 @@ func TestHelpSpawningPointsAtLifecycle(t *testing.T) {
} }
} }
// TestAvailableToolsAdvertisesAllTimerTools makes sure orchestrators
// and sub-agents discover the full timer surface via whoami — not just
// timer_wait. Otherwise agents using whoami for orientation would never
// learn about timer_set, timer_fire_when_idle_*, timer_pause/resume,
// timer_cancel, and timer_list.
func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) {
want := []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} {
tools := availableToolsForRole(role)
for _, w := range want {
if !containsString(tools, w) {
t.Fatalf("role %q missing %q in available tools: %v", role, w, tools)
}
}
}
}
// TestHelpTimersDocumentsAllTools mirrors the whoami check for the
// help("timers") topic — the related-tools list must enumerate every
// timer_* tool so callers reading help can dispatch them.
func TestHelpTimersDocumentsAllTools(t *testing.T) {
resp := helpFor("timers")
if resp.Topic != "timers" {
t.Fatalf("topic: %q", resp.Topic)
}
want := []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, w := range want {
if !containsString(resp.RelatedTools, w) {
t.Fatalf("timers help missing %q in related tools: %v", w, resp.RelatedTools)
}
}
}
func containsString(haystack []string, needle string) bool { func containsString(haystack []string, needle string) bool {
for _, s := range haystack { for _, s := range haystack {
if s == needle { if s == needle {
@@ -172,4 +215,3 @@ func containsString(haystack []string, needle string) bool {
} }
return false return false
} }

View File

@@ -127,20 +127,20 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
// Spawn with the chosen identity. // Spawn with the chosen identity.
cols, rows := l.size() cols, rows := l.size()
c, err := l.sess.Spawn(SpawnSpec{ c, err := l.sess.Spawn(SpawnSpec{
Kind: KindAgent, Kind: KindAgent,
Argv: argv, Argv: argv,
Env: env, Env: env,
Name: displayName, Name: displayName,
ParentID: parentID, ParentID: parentID,
PresetRef: p.Name, PresetRef: p.Name,
Identity: identity, Identity: identity,
CleanupPaths: cleanupPaths, CleanupPaths: cleanupPaths,
IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows) }, cols, rows)
if err != nil { if err != nil {
cleanup() cleanup()
return nil, err return nil, err
} }
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
// Wait for the preset's ready signal, then type the initial prompt. // Wait for the preset's ready signal, then type the initial prompt.
idle := time.Duration(1000) * time.Millisecond idle := time.Duration(1000) * time.Millisecond
@@ -173,18 +173,18 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
} }
cols, rows := l.size() cols, rows := l.size()
c, err := l.sess.Spawn(SpawnSpec{ c, err := l.sess.Spawn(SpawnSpec{
Kind: KindCommand, Kind: KindCommand,
Argv: p.ResolvedArgv(), Argv: p.ResolvedArgv(),
Env: env, Env: env,
Name: displayName, Name: displayName,
ParentID: parentID, ParentID: parentID,
WorkDir: p.WorkingDir, WorkDir: p.WorkingDir,
PresetRef: p.Name, PresetRef: p.Name,
IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows) }, cols, rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
return c, nil return c, nil
} }

View File

@@ -178,6 +178,11 @@ type SpawnSpec struct {
// or is closed. They must be attached before the PTY starts so a // or is closed. They must be attached before the PTY starts so a
// fast-exiting child cannot outrun cleanup registration. // fast-exiting child cannot outrun cleanup registration.
CleanupPaths []string CleanupPaths []string
// IdleDetection is the resolved per-preset idle classifier config.
// Must be installed before the child is published to s.children so
// the classifier goroutine never observes a nil/default config for
// a preset that overrides it.
IdleDetection *resolvedIdleDetection
} }
// Spawn creates a new entry and starts its PTY. For Kind = command the // Spawn creates a new entry and starts its PTY. For Kind = command the
@@ -208,6 +213,12 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
for _, path := range spec.CleanupPaths { for _, path := range spec.CleanupPaths {
c.AddCleanupPath(path) c.AddCleanupPath(path)
} }
// Install idle-detection BEFORE publishing to s.children — otherwise
// the classifier goroutine could read c.idleDetection while the
// launcher is still racing to set it.
if spec.IdleDetection != nil {
c.setIdleDetection(spec.IdleDetection)
}
runID, err := c.startPTY(cols, rows) runID, err := c.startPTY(cols, rows)
if err != nil { if err != nil {
c.cleanupOwnedPaths() c.cleanupOwnedPaths()

View File

@@ -57,6 +57,50 @@ func TestParentExitKillsDescendants(t *testing.T) {
waitUntilNotLive(t, grandchild) waitUntilNotLive(t, grandchild)
} }
// TestSpawnInstallsIdleDetectionBeforePublish guarantees that a child
// spawned with SpawnSpec.IdleDetection has its resolved config visible
// the instant the child appears in s.children — closing the race where
// the classifier could read c.idleDetection before the launcher set it.
func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
want := &resolvedIdleDetection{
strategy: StrategyOSCTitleStability,
idleThresholdMS: 9999,
}
c, err := sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "sleep 30"},
IdleDetection: want,
}, 80, 24)
if err != nil {
t.Fatalf("spawn: %v", err)
}
defer func() { _ = c.signal(syscall.SIGTERM) }()
// Read back via the same access path the classifier uses
// (sess.Children) so the test fails if the field is set only
// AFTER the child is published.
var found *Child
for _, ch := range sess.Children() {
if ch.ID == c.ID {
found = ch
break
}
}
if found == nil {
t.Fatalf("spawned child %s not in Children()", c.ID)
}
if found.idleDetection == nil {
t.Fatalf("idleDetection nil after Spawn returned")
}
if found.idleDetection.strategy != StrategyOSCTitleStability {
t.Fatalf("strategy: got %q want %q", found.idleDetection.strategy, StrategyOSCTitleStability)
}
if found.idleDetection.idleThresholdMS != 9999 {
t.Fatalf("threshold: got %d want 9999", found.idleDetection.idleThresholdMS)
}
}
func waitUntilLive(t *testing.T, c *Child) { func waitUntilLive(t *testing.T, c *Child) {
t.Helper() t.Helper()
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)

View File

@@ -12,9 +12,9 @@ import (
type pendingTimerKind string type pendingTimerKind string
const ( const (
timerKindDelay pendingTimerKind = "delay" timerKindDelay pendingTimerKind = "delay"
timerKindIdleAny pendingTimerKind = "idle_any" timerKindIdleAny pendingTimerKind = "idle_any"
timerKindIdleAll pendingTimerKind = "idle_all" timerKindIdleAll pendingTimerKind = "idle_all"
) )
const ( const (
@@ -39,7 +39,7 @@ type pendingTimer struct {
kind pendingTimerKind kind pendingTimerKind
status string status string
watched []string watched []string
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction) idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
firesAt time.Time firesAt time.Time
@@ -55,9 +55,9 @@ type pendingTimer struct {
type timerManager struct { type timerManager struct {
sess *Session sess *Session
mu sync.Mutex mu sync.Mutex
nextID int nextID int
timers map[string]*pendingTimer timers map[string]*pendingTimer
// fireFn is the callback used to deliver the body to the owning // fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults // process. Decoupled so tests can substitute a recorder. Defaults
@@ -134,6 +134,7 @@ func (m *timerManager) fireDelay(id string) {
t.status = timerStatusFired t.status = timerStatusFired
owner := m.sess.FindChild(t.ownerID) owner := m.sess.FindChild(t.ownerID)
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -228,6 +229,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
t.status = timerStatusFired t.status = timerStatusFired
owner := m.sess.FindChild(t.ownerID) owner := m.sess.FindChild(t.ownerID)
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -247,6 +249,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
label string label string
} }
var fires []firing var fires []firing
var firedIDs []string
for _, t := range m.timers { for _, t := range m.timers {
if t.status != timerStatusPending { if t.status != timerStatusPending {
continue continue
@@ -268,6 +271,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
body: t.body, body: t.body,
label: t.label, label: t.label,
}) })
firedIDs = append(firedIDs, t.id)
case timerKindIdleAll: case timerKindIdleAll:
if m.allWatchedIdleLocked(t) { if m.allWatchedIdleLocked(t) {
t.status = timerStatusFired t.status = timerStatusFired
@@ -279,9 +283,13 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
body: t.body, body: t.body,
label: t.label, label: t.label,
}) })
firedIDs = append(firedIDs, t.id)
} }
} }
} }
for _, id := range firedIDs {
delete(m.timers, id)
}
m.mu.Unlock() m.mu.Unlock()
for _, f := range fires { for _, f := range fires {
m.fireFn(f.owner, f.body, f.label) m.fireFn(f.owner, f.body, f.label)
@@ -327,6 +335,7 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
t.rt = nil t.rt = nil
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id)
return nil return nil
} }
@@ -367,20 +376,29 @@ func (m *timerManager) TimerPause(ownerID, id string) error {
// TimerResume re-arms a paused timer. For delay timers the remaining // TimerResume re-arms a paused timer. For delay timers the remaining
// duration is restored; idle-* timers re-attach to the state-change // duration is restored; idle-* timers re-attach to the state-change
// watch list, and any remaining max-wait clock resumes. // watch list, and any remaining max-wait clock resumes.
//
// Idle-* timers also re-check their satisfaction condition immediately
// on resume: idle transitions that occurred while paused are otherwise
// missed (onChildStateChanged only sees future flips), so a child that
// went idle during the pause window would never fire the timer. For
// idle_any we look for any non-baseline watched child currently idle;
// for idle_all we check whether every watched child is now idle.
func (m *timerManager) TimerResume(ownerID, id string) error { func (m *timerManager) TimerResume(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status != timerStatusPaused { if t.status != timerStatusPaused {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id) return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
} }
t.status = timerStatusPending t.status = timerStatusPending
@@ -397,6 +415,42 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
t.pausedRemaining = 0 t.pausedRemaining = 0
t.pausedWasMaxWait = false t.pausedWasMaxWait = false
} }
// For idle-* timers, evaluate the condition right now in case a
// watched child went idle while paused.
var fireNow bool
var owner *Child
var body, label string
switch t.kind {
case timerKindIdleAny:
for _, wid := range t.watched {
if t.idleBaseline[wid] {
continue
}
c := m.sess.FindChild(wid)
if c != nil && isIdleState(c.IdleState()) {
fireNow = true
break
}
}
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
fireNow = true
}
}
if fireNow {
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
owner = m.sess.FindChild(t.ownerID)
body, label = t.body, t.label
delete(m.timers, id)
}
m.mu.Unlock()
if fireNow {
m.fireFn(owner, body, label)
}
return nil return nil
} }

View File

@@ -221,3 +221,193 @@ func TestTimerOwnershipEnforced(t *testing.T) {
t.Fatal("expected ownership error from foreign pause") 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)
}
}