Fix idle timer review issues
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -108,6 +108,25 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
renders the canonical `--flag` form.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -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]
|
||||
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
|
||||
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
|
||||
|
||||
@@ -1084,7 +1084,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
|
||||
"list_processes", "get_process_status", "get_project_status",
|
||||
"get_process_output", "get_process_raw_output", "search_output",
|
||||
"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",
|
||||
"whoami", "help",
|
||||
}
|
||||
@@ -1145,8 +1147,17 @@ func helpFor(topic string) mcp.HelpResponse {
|
||||
case "timers":
|
||||
return mcp.HelpResponse{
|
||||
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.",
|
||||
RelatedTools: []string{"timer_wait"},
|
||||
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. " +
|
||||
"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":
|
||||
return mcp.HelpResponse{
|
||||
|
||||
@@ -3,6 +3,8 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
@@ -172,4 +215,3 @@ func containsString(haystack []string, needle string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -135,12 +135,12 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
||||
PresetRef: p.Name,
|
||||
Identity: identity,
|
||||
CleanupPaths: cleanupPaths,
|
||||
IdleDetection: resolveIdleDetection(p.IdleDetection),
|
||||
}, cols, rows)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
|
||||
|
||||
// Wait for the preset's ready signal, then type the initial prompt.
|
||||
idle := time.Duration(1000) * time.Millisecond
|
||||
@@ -180,11 +180,11 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
|
||||
ParentID: parentID,
|
||||
WorkDir: p.WorkingDir,
|
||||
PresetRef: p.Name,
|
||||
IdleDetection: resolveIdleDetection(p.IdleDetection),
|
||||
}, cols, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,11 @@ type SpawnSpec struct {
|
||||
// or is closed. They must be attached before the PTY starts so a
|
||||
// fast-exiting child cannot outrun cleanup registration.
|
||||
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
|
||||
@@ -208,6 +213,12 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
|
||||
for _, path := range spec.CleanupPaths {
|
||||
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)
|
||||
if err != nil {
|
||||
c.cleanupOwnedPaths()
|
||||
|
||||
@@ -57,6 +57,50 @@ func TestParentExitKillsDescendants(t *testing.T) {
|
||||
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) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
@@ -134,6 +134,7 @@ func (m *timerManager) fireDelay(id string) {
|
||||
t.status = timerStatusFired
|
||||
owner := m.sess.FindChild(t.ownerID)
|
||||
body, label := t.body, t.label
|
||||
delete(m.timers, id)
|
||||
m.mu.Unlock()
|
||||
m.fireFn(owner, body, label)
|
||||
}
|
||||
@@ -228,6 +229,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
|
||||
t.status = timerStatusFired
|
||||
owner := m.sess.FindChild(t.ownerID)
|
||||
body, label := t.body, t.label
|
||||
delete(m.timers, id)
|
||||
m.mu.Unlock()
|
||||
m.fireFn(owner, body, label)
|
||||
}
|
||||
@@ -247,6 +249,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
||||
label string
|
||||
}
|
||||
var fires []firing
|
||||
var firedIDs []string
|
||||
for _, t := range m.timers {
|
||||
if t.status != timerStatusPending {
|
||||
continue
|
||||
@@ -268,6 +271,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
||||
body: t.body,
|
||||
label: t.label,
|
||||
})
|
||||
firedIDs = append(firedIDs, t.id)
|
||||
case timerKindIdleAll:
|
||||
if m.allWatchedIdleLocked(t) {
|
||||
t.status = timerStatusFired
|
||||
@@ -279,9 +283,13 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
||||
body: t.body,
|
||||
label: t.label,
|
||||
})
|
||||
firedIDs = append(firedIDs, t.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range firedIDs {
|
||||
delete(m.timers, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, f := range fires {
|
||||
m.fireFn(f.owner, f.body, f.label)
|
||||
@@ -327,6 +335,7 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
|
||||
t.rt = nil
|
||||
}
|
||||
t.status = timerStatusCanceled
|
||||
delete(m.timers, id)
|
||||
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
|
||||
// duration is restored; idle-* timers re-attach to the state-change
|
||||
// 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 {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.timers[id]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
||||
}
|
||||
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
||||
// MCP client); allow it to manage every timer in the session.
|
||||
// Otherwise the caller's own id must match the timer's owner.
|
||||
if ownerID != "" && t.ownerID != ownerID {
|
||||
m.mu.Unlock()
|
||||
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
||||
}
|
||||
if t.status != timerStatusPaused {
|
||||
m.mu.Unlock()
|
||||
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
|
||||
}
|
||||
t.status = timerStatusPending
|
||||
@@ -397,6 +415,42 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
|
||||
t.pausedRemaining = 0
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -221,3 +221,193 @@ func TestTimerOwnershipEnforced(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user