Cancel pending timers when a child is closed (#6)
Co-authored-by: Harry Bayliss <harry@hjb.dev> Co-committed-by: Harry Bayliss <harry@hjb.dev>
This commit was merged in pull request #6.
This commit is contained in:
@@ -296,6 +296,65 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
||||
}
|
||||
}
|
||||
|
||||
// onChildClosed drops pending timer references to childID. Called
|
||||
// from Session.Close (and the terminal-corpse cleanup in reapChild)
|
||||
// via the session listener bus — a deliberate signal from the host
|
||||
// that childID is gone and the parent is not waiting on it anymore.
|
||||
//
|
||||
// Semantics:
|
||||
// - timers owned by childID are cancelled and deleted: their owner
|
||||
// is gone, so even if defaultFireFn's IsLive guard would no-op
|
||||
// the delivery, the entry has no business surviving a close.
|
||||
// - timers watching childID have childID pruned from t.watched
|
||||
// (and t.idleBaseline). If t.watched becomes empty the timer is
|
||||
// cancelled and deleted; we deliberately do NOT synthesise a
|
||||
// fire here. The parent already received any legitimate idle
|
||||
// transition before close_process — see allWatchedIdleLocked's
|
||||
// "treat as satisfied" comment, which only applies to a
|
||||
// concurrent re-evaluation, not to this explicit-removal hook.
|
||||
//
|
||||
// The natural-exit path (reapChild → emitExit for agent/command
|
||||
// kinds) is NOT routed through here: the classifier emits a final
|
||||
// idle transition on exit, which fires and deletes any watching
|
||||
// timers exactly once. Cancelling on exit would swallow that
|
||||
// legitimate fire and leave the parent never notified.
|
||||
func (m *timerManager) onChildClosed(childID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for id, t := range m.timers {
|
||||
if t.ownerID == childID {
|
||||
if t.rt != nil {
|
||||
t.rt.Stop()
|
||||
t.rt = nil
|
||||
}
|
||||
t.status = timerStatusCanceled
|
||||
delete(m.timers, id)
|
||||
continue
|
||||
}
|
||||
if !contains(t.watched, childID) {
|
||||
continue
|
||||
}
|
||||
pruned := t.watched[:0]
|
||||
for _, w := range t.watched {
|
||||
if w != childID {
|
||||
pruned = append(pruned, w)
|
||||
}
|
||||
}
|
||||
t.watched = pruned
|
||||
if t.idleBaseline != nil {
|
||||
delete(t.idleBaseline, childID)
|
||||
}
|
||||
if len(t.watched) == 0 {
|
||||
if t.rt != nil {
|
||||
t.rt.Stop()
|
||||
t.rt = nil
|
||||
}
|
||||
t.status = timerStatusCanceled
|
||||
delete(m.timers, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allWatchedIdleLocked reports whether every watched child is now
|
||||
// idle. Called with m.mu held — uses live Child.IdleState() under the
|
||||
// child's own atomic, not under m.mu.
|
||||
|
||||
Reference in New Issue
Block a user