Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ reported at the top level. A single-host response omits `machines`. Consumers
should read the top-level primary report plus any additional `machines` entries.
Summary reads only the latest health sample and does not build machine trends.

An orphan tree counts as idle when every process stays below 5% of one core,
the whole tree stays below 25%, and no process has a socket peer outside the
tree. Dead helpers still tick timers and garbage collection, so exact-zero CPU
is not required. Listening sockets and connections to other users' processes,
such as system services, also keep a tree. A helper whose signature sets
`idle_without_clients` is the exception: its own listeners do not count, and
when it is the tree root, neither do system-service connections in the tree.
A connection to another of your processes, any network connection, and any
other process's listener, such as a dev server a child started, still keep the
tree. The Codex plugin's `app-server-broker.mjs` and `claude mcp serve` set it,
because their clients start a replacement or use stdio. A peer that has already
closed does not count. On macOS, telling a listener's clients from system
services takes one extra `lsof` pass over all your processes, run only for
trees with such a helper; if it fails, every socket counts as in use. A process
that runs from a running app's bundle protects its tree, unless an agent helper
started it to borrow the bundle's runtime.

Remote orphans cannot be killed here; run the kill command on their machine.
Remote inbox acknowledgments require the peer's `--allow-ack` grant; unreachable peers
show `ack pending` and retry on reconnect.
Expand Down Expand Up @@ -922,6 +939,8 @@ name = "example-helper"
agent = "codex"
role = "helper"
match = ['^example-agent-helper([[:space:]]|$)']
# Optional: when this helper roots a tree, only real clients keep the tree.
# idle_without_clients = true
```

Extra files are read in configuration order before built-in signatures; first
Expand Down
10 changes: 8 additions & 2 deletions internal/actions/kill.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ import (
"github.com/labstack/driga/internal/derive"
"github.com/labstack/driga/internal/kill"
"github.com/labstack/driga/internal/model"
"github.com/labstack/driga/internal/signatures"
)

type KillOptions struct{ All, Force bool }

func (c KillOptions) Kill(ctx context.Context, g Environment, cfg config.Config, procs []model.Proc, candidates []model.Orphan) error {
idx := derive.NewProcIndex(procs)
sigs, err := signatures.Load(cfg.ExtraSignatures)
if err != nil {
return err
}
classified := kill.WithClassifier(derive.NewClassifier(sigs, len(procs)))
var errs []error
for _, o := range candidates {
if o.Young {
Expand Down Expand Up @@ -50,7 +56,7 @@ func (c KillOptions) Kill(ctx context.Context, g Environment, cfg config.Config,
if _, err := fmt.Fprintf(g.Stdout, "%s: checking it stays idle for %s…\n", o.ID, g.VerifyWindow); err != nil {
return err
}
ok, reason, err := kill.VerifyIdle(ctx, g.Platform(), o, expect, g.VerifyWindow)
ok, reason, err := kill.VerifyIdle(ctx, g.Platform(), o, expect, g.VerifyWindow, classified)
if err == nil && !ok {
err = errors.New("not idle: " + reason)
}
Expand Down Expand Up @@ -106,7 +112,7 @@ func (c KillOptions) Kill(ctx context.Context, g Environment, cfg config.Config,
return err
}
}
result, killErr := kill.Tree(ctx, g.Platform(), o, expect, c.Force, 5*time.Second)
result, killErr := kill.Tree(ctx, g.Platform(), o, expect, c.Force, 5*time.Second, classified)
if result.Aborted {
killErr = fmt.Errorf("kill %s aborted: %s", o.ID, result.Reason)
}
Expand Down
8 changes: 5 additions & 3 deletions internal/cli/act_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ func (p *cliSeqPlatform) Machine(context.Context) (model.MachineSample, error) {
}
func (p *cliSeqPlatform) Cwds(context.Context, []int32) (map[int32]string, error) { return nil, nil }
func (p *cliSeqPlatform) Alive(int32, time.Time) bool { return false }
func (p *cliSeqPlatform) ExternalPeers(context.Context, []int32) (map[int32]bool, error) {
return p.peers, nil
func (p *cliSeqPlatform) ExternalPeers(context.Context, []int32) (map[int32]model.PeerState, error) {
return platform.FixturePeers(p.peers), nil
}

var _ platform.SocketProber = (*cliSeqPlatform)(nil)

// Snapshot mode never accumulates two passes, so an unverified candidate is
// verified on demand instead of being refused outright.
func TestKillVerifiesUnverifiedOnDemand(t *testing.T) {
Expand All @@ -74,7 +76,7 @@ func TestKillVerifiesUnverifiedOnDemand(t *testing.T) {
prompt bool
want string
}{
{"busy", &cliSeqPlatform{snaps: [][]model.Proc{tree(1), tree(2)}}, false, "not idle: used 1.00s CPU"},
{"busy", &cliSeqPlatform{snaps: [][]model.Proc{tree(1), tree(2)}}, false, "not idle: pid 4242 used 1.00s CPU"},
{"connected", &cliSeqPlatform{snaps: [][]model.Proc{tree(1)}, peers: map[int32]bool{4242: true}}, false, "not idle: pid 4242 has a connection outside the tree"},
{"quiet", &cliSeqPlatform{snaps: [][]model.Proc{tree(1)}}, true, "verified idle"},
} {
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/top.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/labstack/driga/internal/chat"
"github.com/labstack/driga/internal/cleanup"
"github.com/labstack/driga/internal/config"
"github.com/labstack/driga/internal/signatures"
"github.com/labstack/driga/internal/tui"
"github.com/labstack/driga/internal/view"
)
Expand Down Expand Up @@ -66,6 +67,9 @@ func (c TopCmd) Run(g *Globals) error {
opts.Tmux = os.Getenv("TMUX")
opts.JumpTerminal = cfg.Inbox.JumpTerminal
opts.MotionOff = cfg.Motion == "off"
if opts.Signatures, err = signatures.Load(cfg.ExtraSignatures); err != nil {
return err
}
opts.CleanupList = func(ctx context.Context) ([]cleanup.Candidate, error) {
envelope, err := cleanupRead(ctx, g, "", false, true)
if err != nil {
Expand Down
7 changes: 5 additions & 2 deletions internal/collector/orphan_socket_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/labstack/driga/internal/model"
"github.com/labstack/driga/internal/platform"
"github.com/labstack/driga/internal/spool"
)

Expand All @@ -16,9 +17,11 @@ type socketPlatform struct {
requested []int32
}

func (p *socketPlatform) ExternalPeers(_ context.Context, pids []int32) (map[int32]bool, error) {
var _ platform.SocketProber = (*socketPlatform)(nil)

func (p *socketPlatform) ExternalPeers(_ context.Context, pids []int32) (map[int32]model.PeerState, error) {
p.requested = append([]int32(nil), pids...)
return p.peers, nil
return platform.FixturePeers(p.peers), nil
}

func TestPassVerifiesOrphansViaSocketProber(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/collector/pass.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ func Pass(ctx context.Context, d Deps, st *store.Store, p Plan) (PassReport, err
orphanOpts = append(orphanOpts, derive.WithSubreaper(collected.subreaper))
}
orphans := derive.FindOrphans(idx, ss, d.Sigs, now, d.Cfg.OrphanMinAge, prev, orphanOpts...)
var peers map[int32]bool
var peers map[int32]model.PeerState
if _, ok := d.Plat.(platform.SocketProber); ok && p.Sockets && len(orphans) > 0 {
peers = collected.peers
if e := source("sockets", collected.socketErr); e != nil {
Expand Down
21 changes: 17 additions & 4 deletions internal/collector/sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ type sourceResults struct {
gitErr error
feedEnabled bool
spool spoolResult
peers map[int32]bool
peers map[int32]model.PeerState
socketErr error
subreaper int32
paths map[string]string
Expand Down Expand Up @@ -622,7 +622,7 @@ func spoolHooks(d Deps, events []model.HookEvent) []model.HookEvent {
return hooks
}

func collectSocketPeers(ctx context.Context, d Deps, st *store.Store, state *State, procs []model.Proc, ss []model.Session, procAt, now time.Time, subreaper int32, hooks []model.HookEvent, aliases map[int64]int) (map[int32]bool, error) {
func collectSocketPeers(ctx context.Context, d Deps, st *store.Store, state *State, procs []model.Proc, ss []model.Session, procAt, now time.Time, subreaper int32, hooks []model.HookEvent, aliases map[int64]int) (map[int32]model.PeerState, error) {
probe, ok := d.Plat.(platform.SocketProber)
if !ok {
return nil, nil
Expand Down Expand Up @@ -695,16 +695,29 @@ func collectSocketPeers(ctx context.Context, d Deps, st *store.Store, state *Sta
memory[key] = id
}
}
candidates := derive.FindOrphans(idx, sessions, d.Sigs, now, d.Cfg.OrphanMinAge, prev, derive.WithSpawnMemory(memory), derive.WithSubreaper(subreaper), derive.WithClassifier(d.classifier))
// FindOrphans waives listeners by signature, so the probe must resolve
// clients with the same classifier.
classifier := d.classifier
if classifier == nil && d.Sigs != nil {
classifier = derive.NewClassifier(d.Sigs, len(procs))
}
candidates := derive.FindOrphans(idx, sessions, d.Sigs, now, d.Cfg.OrphanMinAge, prev, derive.WithSpawnMemory(memory), derive.WithSubreaper(subreaper), derive.WithClassifier(classifier))
var pids []int32
var members []model.Proc
for _, o := range candidates {
pids = append(pids, o.PIDs...)
for _, pid := range o.PIDs {
if p, ok := idx.Get(pid); ok {
members = append(members, p)
}
}
}
resolve := derive.NeedsResolvedPeers(classifier, members)
if len(pids) == 0 {
return nil, nil
}
sort.Slice(pids, func(i, j int) bool { return pids[i] < pids[j] })
peers, err := probe.ExternalPeers(ctx, pids)
peers, err := platform.ProbePeers(ctx, probe, pids, resolve)
if err != nil {
return nil, err
}
Expand Down
70 changes: 65 additions & 5 deletions internal/derive/orphans.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,57 @@ import (
"github.com/labstack/driga/internal/signatures"
)

// BackgroundCPUPercent is the per-process share of one core below which an
// orphan tree member counts as idle, and BackgroundTreeCPUPercent bounds the
// whole tree. Dead helpers still tick timers and GC far below these; real work
// runs far above them. External peers stay the liveness proof for work that
// waits on the network.
const (
BackgroundCPUPercent = 5.0
BackgroundTreeCPUPercent = 25.0
)

// IdleWithoutClients reports whether p's signature sets IdleWithoutClients.
func IdleWithoutClients(c *Classifier, p model.Proc) bool {
if c == nil {
return false
}
sig, ok := c.Match(p)
return ok && sig.IdleWithoutClients
}

// NeedsResolvedPeers reports whether InUse may waive a socket in tree, so its
// probe must tell a listener's clients and system services apart
// (platform.PeerResolver).
func NeedsResolvedPeers(c *Classifier, tree []model.Proc) bool {
for _, p := range tree {
if IdleWithoutClients(c, p) {
return true
}
}
return false
}

// InUse reports whether member p's socket state shows its tree still serves
// someone. A listener counts unless p's own signature sets IdleWithoutClients;
// a system-service socket counts unless the tree root's does.
func InUse(c *Classifier, root, p model.Proc, state model.PeerState) bool {
switch state {
case model.PeerConnected:
return true
case model.PeerListening:
return !IdleWithoutClients(c, p)
case model.PeerSystem:
return !IdleWithoutClients(c, root)
}
return false
}

// OrphanOption supplies remembered observations from earlier process passes.
type OrphanOption func(*orphanOptions)
type orphanOptions struct {
spawnMemory map[string]int64
externalPeers map[int32]bool
externalPeers map[int32]model.PeerState
subreaper int32
classifier *Classifier
}
Expand All @@ -38,7 +84,7 @@ func WithSubreaper(pid int32) OrphanOption {
// Collectors must probe every tree pid each process pass, pass only successful
// results here, and use the immediately preceding pass as prev. Missing results
// leave candidates unverified. Verified also needs persistence in stored views.
func WithExternalPeers(peers map[int32]bool) OrphanOption {
func WithExternalPeers(peers map[int32]model.PeerState) OrphanOption {
return func(o *orphanOptions) { o.externalPeers = peers }
}

Expand Down Expand Up @@ -147,13 +193,15 @@ func FindOrphans(idx ProcIndex, sessions []model.Session, sigs *signatures.Set,
idle := true
probed := true
var matched string
var treeCPU float64
// visit applies the idle checks to one tree member; false stops the walk.
visit := func(p model.Proc) bool {
if protected[p.PID] {
idle = false
return false
}
if p.CPUPercent != 0 || options.externalPeers[p.PID] {
treeCPU += p.CPUPercent
if p.CPUPercent >= BackgroundCPUPercent || treeCPU >= BackgroundTreeCPUPercent || InUse(classifier, r, p, options.externalPeers[p.PID]) {
idle = false
return false
}
Expand All @@ -164,7 +212,9 @@ func FindOrphans(idx ProcIndex, sessions []model.Session, sigs *signatures.Set,
idle = false
return false
}
if bundle := orphanBundle(p); bundle != "" && apps[bundle] {
// A process from a running app's bundle belongs to that app, unless an
// agent helper started it to borrow the bundle's runtime.
if bundle := orphanBundle(p); bundle != "" && apps[bundle] && (p.PID == pid || !agentHelper(classifier, idx, p.PPID)) {
idle = false
return false
}
Expand Down Expand Up @@ -236,7 +286,7 @@ func FindOrphans(idx ProcIndex, sessions []model.Session, sigs *signatures.Set,
}
o.ProcCount = len(o.PIDs)
if probed {
const idleEvidence = "no CPU or external peers"
const idleEvidence = "background CPU only, no external peers"
o.Evidence = append(o.Evidence, idleEvidence)
o.Verified = existed && old.StartTime.Equal(o.StartTime) &&
slices.Equal(old.PIDs, o.PIDs) && slices.Contains(old.Evidence, idleEvidence)
Expand Down Expand Up @@ -280,6 +330,16 @@ func FindOrphans(idx ProcIndex, sessions []model.Session, sigs *signatures.Set,
return result
}

// agentHelper reports whether pid is a known agent helper, host or headless run.
func agentHelper(c *Classifier, idx ProcIndex, pid int32) bool {
parent, ok := idx.Get(pid)
if !ok || c == nil {
return false
}
sig, ok := c.Match(parent)
return ok && (sig.Role == signatures.RoleHelper || sig.Role == signatures.RoleHost || sig.Role == signatures.RoleHeadless)
}

func orphanBundle(p model.Proc) string {
if bundle, ok := signatures.BundlePath(p.Cmdline); ok {
return bundle
Expand Down
Loading
Loading