103 lines
2.8 KiB
Go
103 lines
2.8 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// classifierTickInterval is how often the per-session classifier wakes
|
|
// up to re-evaluate every child's state. 250ms is fast enough that
|
|
// the sidebar badge looks live, slow enough that the cost is invisible
|
|
// even with dozens of children.
|
|
const classifierTickInterval = 250 * time.Millisecond
|
|
|
|
// classifierTailBytes is the size of the ring-buffer tail the
|
|
// classifier scans for promoter regexes. Big enough to catch a multi-
|
|
// line "Approve?" prompt, small enough that we don't pay for a full
|
|
// 1 MiB regex scan every tick.
|
|
const classifierTailBytes = 4096
|
|
|
|
// runClassifier loops over every live child every classifierTickInterval
|
|
// and updates IdleState when it changes. It runs until ctx is cancelled
|
|
// (the host shutdown path cancels). One goroutine per Session is plenty
|
|
// — the work is cheap (atomic loads + ~4 KiB regex scan per child).
|
|
func (s *Session) runClassifier(ctx context.Context) {
|
|
ticker := time.NewTicker(classifierTickInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.classifyAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) classifyAll() {
|
|
for _, c := range s.Children() {
|
|
s.classifyOne(c)
|
|
}
|
|
}
|
|
|
|
func (s *Session) classifyOne(c *Child) {
|
|
st := c.Status()
|
|
exited := st == StatusExited || st == StatusErrored
|
|
exitNonZero := false
|
|
if exited {
|
|
exitNonZero = c.ExitCode() != 0
|
|
}
|
|
idleMS := c.IdleMS()
|
|
titleIdleMS := c.TitleIdleMS()
|
|
title := c.Title()
|
|
tail := stripANSIBytes(nil, c.tailBytes(classifierTailBytes))
|
|
var screen []byte
|
|
if em := c.Emulator(); em != nil {
|
|
if txt, err := em.ScreenText(); err == nil {
|
|
screen = []byte(txt)
|
|
}
|
|
}
|
|
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen)
|
|
if c.setIdleState(state, reason) {
|
|
s.emitStateChanged(c.ID, state)
|
|
}
|
|
}
|
|
|
|
// tailBytes returns up to n bytes from the end of the ring buffer.
|
|
// Safe to call from the classifier goroutine while pumpChild writes
|
|
// from another goroutine — both serialise on ringMu.
|
|
func (c *Child) tailBytes(n int) []byte {
|
|
c.ringMu.Lock()
|
|
defer c.ringMu.Unlock()
|
|
have := int64(ringCap)
|
|
if !c.ringFull {
|
|
have = c.ringWrites
|
|
}
|
|
if have == 0 {
|
|
return nil
|
|
}
|
|
want := int64(n)
|
|
if want > have {
|
|
want = have
|
|
}
|
|
out := make([]byte, want)
|
|
// The ring layout matches StreamRead: when not full, byte k lives
|
|
// at index k; when full, the oldest byte sits at ringPos and the
|
|
// newest at (ringPos-1) mod ringCap.
|
|
if !c.ringFull {
|
|
copy(out, c.ring[c.ringWrites-want:c.ringWrites])
|
|
return out
|
|
}
|
|
// Tail starts `want` bytes back from the write head.
|
|
start := (c.ringPos - int(want) + ringCap) % ringCap
|
|
first := ringCap - start
|
|
if first > int(want) {
|
|
first = int(want)
|
|
}
|
|
copy(out, c.ring[start:start+first])
|
|
if first < int(want) {
|
|
copy(out[first:], c.ring[:int(want)-first])
|
|
}
|
|
return out
|
|
}
|