perf(tmux): batch session queries in gt down (#477)

* perf(tmux): batch session queries in gt down to reduce N+1 subprocess calls

Add SessionSet type to tmux package for O(1) session existence checks.
Instead of calling HasSession() (which spawns a subprocess) for each
rig/session during shutdown, now calls ListSessions() once and uses
in-memory map lookups.

Changes:
- internal/tmux/tmux.go: Add SessionSet type with GetSessionSet() and Has()
- internal/cmd/down.go: Use SessionSet for dry-run checks and session stops
- internal/session/town.go: Add StopTownSessionWithCache() variant
- internal/tmux/tmux_test.go: Add test for SessionSet

With 5 rigs, this reduces subprocess calls from ~15 to 1 during shutdown
preview, saving 60-150ms of execution time.

Closes: gt-xh2bh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* perf(tmux): optimize SessionSet to avoid intermediate slice allocation

- Build map directly from tmux output instead of calling ListSessions()
- Use strings.IndexByte for efficient newline parsing
- Pre-size map using newline count to avoid rehashing
- Simplify nil checks in Has() and Names()

* fix(sling): restore bd cook directory context for formula-on-bead mode

The bd cook command needs to run from the target rig's directory to
access the correct formula database. This was accidentally removed
in a previous commit, causing TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig
to fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Keith Wyatt
2026-01-13 22:07:05 -08:00
committed by GitHub
parent 5d96243414
commit 08755f62cd
5 changed files with 162 additions and 7 deletions

View File

@@ -244,6 +244,72 @@ func (t *Tmux) ListSessions() ([]string, error) {
return strings.Split(out, "\n"), nil
}
// SessionSet provides O(1) session existence checks by caching session names.
// Use this when you need to check multiple sessions to avoid N+1 subprocess calls.
type SessionSet struct {
sessions map[string]struct{}
}
// GetSessionSet returns a SessionSet containing all current sessions.
// Call this once at the start of an operation, then use Has() for O(1) checks.
// This replaces multiple HasSession() calls with a single ListSessions() call.
//
// Builds the map directly from tmux output to avoid intermediate slice allocation.
func (t *Tmux) GetSessionSet() (*SessionSet, error) {
out, err := t.run("list-sessions", "-F", "#{session_name}")
if err != nil {
if errors.Is(err, ErrNoServer) {
return &SessionSet{sessions: make(map[string]struct{})}, nil
}
return nil, err
}
// Count newlines to pre-size map (avoids rehashing during insertion)
count := strings.Count(out, "\n") + 1
set := &SessionSet{
sessions: make(map[string]struct{}, count),
}
// Parse directly without intermediate slice allocation
for len(out) > 0 {
idx := strings.IndexByte(out, '\n')
var line string
if idx >= 0 {
line = out[:idx]
out = out[idx+1:]
} else {
line = out
out = ""
}
if line != "" {
set.sessions[line] = struct{}{}
}
}
return set, nil
}
// Has returns true if the session exists in the set.
// This is an O(1) lookup - no subprocess is spawned.
func (s *SessionSet) Has(name string) bool {
if s == nil {
return false
}
_, ok := s.sessions[name]
return ok
}
// Names returns all session names in the set.
func (s *SessionSet) Names() []string {
if s == nil || len(s.sessions) == 0 {
return nil
}
names := make([]string, 0, len(s.sessions))
for name := range s.sessions {
names = append(names, name)
}
return names
}
// ListSessionIDs returns a map of session name to session ID.
// Session IDs are in the format "$N" where N is a number.
func (t *Tmux) ListSessionIDs() (map[string]string, error) {

View File

@@ -552,3 +552,56 @@ func TestGetAllDescendants(t *testing.T) {
}
}
}
func TestSessionSet(t *testing.T) {
if !hasTmux() {
t.Skip("tmux not installed")
}
tm := NewTmux()
sessionName := "gt-test-sessionset-" + t.Name()
// Clean up any existing session
_ = tm.KillSession(sessionName)
// Create a test session
if err := tm.NewSession(sessionName, ""); err != nil {
t.Fatalf("NewSession: %v", err)
}
defer func() { _ = tm.KillSession(sessionName) }()
// Get the session set
set, err := tm.GetSessionSet()
if err != nil {
t.Fatalf("GetSessionSet: %v", err)
}
// Test Has() for existing session
if !set.Has(sessionName) {
t.Errorf("SessionSet.Has(%q) = false, want true", sessionName)
}
// Test Has() for non-existing session
if set.Has("nonexistent-session-xyz-12345") {
t.Error("SessionSet.Has(nonexistent) = true, want false")
}
// Test nil safety
var nilSet *SessionSet
if nilSet.Has("anything") {
t.Error("nil SessionSet.Has() = true, want false")
}
// Test Names() returns the session
names := set.Names()
found := false
for _, n := range names {
if n == sessionName {
found = true
break
}
}
if !found {
t.Errorf("SessionSet.Names() doesn't contain %q", sessionName)
}
}