Skip to content

Commit 633e8bd

Browse files
andyrewleeclaude
andauthored
Performance improvement (#176)
* Fix git status performance by splitting fast/full modes and adding periodic orphan GC Hot paths (startup, 3s tick, file watcher, StatusManager background refresh) now use GetStatusFast which runs only git status --porcelain, skipping the two 10s-timeout diff --numstat calls and untracked file line counting. Full status with line stats is reserved for workspace activation and user-initiated sidebar refresh. The sidebar preserves existing line stats when fast-mode updates arrive to prevent flicker. Also adds a 60s periodic tmux orphan GC ticker, session count logging at startup, and temp file cleanup (EXIT trap in openclaw-dx.sh, KEEP_TEMP defaulting to false in openclaw-dogfood.sh with REPORT_DIR cleanup). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix perf regression follow-ups from review * fix orphan gc cross-instance session safety * fix sidebar line stats refresh on active file changes * ensure git status ticker preserves active line stats --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 46e839e commit 633e8bd

19 files changed

Lines changed: 586 additions & 38 deletions

internal/app/app_init.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ func (a *App) Init() tea.Cmd {
168168
a.sidebarTerminal.Init(),
169169
a.startGitStatusTicker(),
170170
a.startPTYWatchdog(),
171+
a.startOrphanGCTicker(),
171172
a.startTmuxActivityTicker(),
172173
a.triggerTmuxActivityScan(),
173174
a.startTmuxSyncTicker(),
@@ -231,6 +232,13 @@ func (a *App) startGitStatusTicker() tea.Cmd {
231232
})
232233
}
233234

235+
// startOrphanGCTicker returns a command that ticks periodically to clean up orphaned tmux sessions.
236+
func (a *App) startOrphanGCTicker() tea.Cmd {
237+
return common.SafeTick(orphanGCInterval, func(time.Time) tea.Msg {
238+
return messages.OrphanGCTick{}
239+
})
240+
}
241+
234242
// startPTYWatchdog ticks periodically to ensure PTY readers are running.
235243
func (a *App) startPTYWatchdog() tea.Cmd {
236244
return common.SafeTick(ptyWatchdogInterval, func(time.Time) tea.Msg {

internal/app/app_input.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) {
274274
case messages.GitStatusTick:
275275
cmds = append(cmds, a.handleGitStatusTick()...)
276276

277+
case messages.OrphanGCTick:
278+
cmds = append(cmds, a.handleOrphanGCTick()...)
279+
277280
case messages.PTYWatchdogTick:
278281
cmds = append(cmds, a.handlePTYWatchdogTick()...)
279282
case tmuxActivityTick:
@@ -295,6 +298,8 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) {
295298
a.handleOrphanGCResult(msg)
296299
case terminalGCResult:
297300
a.handleTerminalGCResult(msg)
301+
case sessionCountResult:
302+
a.handleSessionCountResult(msg)
298303

299304
case messages.FileWatcherEvent:
300305
cmds = append(cmds, a.handleFileWatcherEvent(msg)...)

internal/app/app_input_messages_workspace.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ func (a *App) handleProjectsLoaded(msg messages.ProjectsLoaded) []tea.Cmd {
3232
if gcCmd := a.gcStaleTerminalSessions(); gcCmd != nil {
3333
cmds = append(cmds, gcCmd)
3434
}
35+
if countCmd := a.logSessionCount(); countCmd != nil {
36+
cmds = append(cmds, countCmd)
37+
}
3538
for i := range a.projects {
3639
for j := range a.projects[i].Workspaces {
3740
ws := &a.projects[i].Workspaces[j]
@@ -267,9 +270,9 @@ func (a *App) handleWorkspaceActivated(msg messages.WorkspaceActivated) []tea.Cm
267270
a.dashboard = newDashboard
268271
cmds = append(cmds, cmd)
269272

270-
// Refresh git status for sidebar
273+
// Refresh git status for sidebar (full mode for line stats)
271274
if msg.Workspace != nil {
272-
cmds = append(cmds, a.requestGitStatus(msg.Workspace.Root))
275+
cmds = append(cmds, a.requestGitStatusFull(msg.Workspace.Root))
273276
// Set up file watching for this workspace
274277
if a.fileWatcher != nil {
275278
if err := a.fileWatcher.Watch(msg.Workspace.Root); err != nil {

internal/app/app_input_pty.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func (a *App) handleSidebarPTYMessages(msg tea.Msg) tea.Cmd {
2828
func (a *App) handleGitStatusTick() []tea.Cmd {
2929
var cmds []tea.Cmd
3030
if a.activeWorkspace != nil {
31-
cmds = append(cmds, a.requestGitStatusCached(a.activeWorkspace.Root))
31+
cmds = append(cmds, a.requestGitStatusCached(a.activeWorkspace.Root, true))
3232
}
3333
// Refresh active workspace indicators even when no PTY output is flowing.
3434
a.syncActiveWorkspacesToDashboard()
@@ -39,19 +39,25 @@ func (a *App) handleGitStatusTick() []tea.Cmd {
3939
// handleFileWatcherEvent handles the FileWatcherEvent message.
4040
func (a *App) handleFileWatcherEvent(msg messages.FileWatcherEvent) []tea.Cmd {
4141
requestRoot := msg.Root
42+
requestFull := false
4243
if a.gitStatus != nil {
4344
a.gitStatus.Invalidate(msg.Root)
4445
}
4546
a.dashboard.InvalidateStatus(msg.Root)
4647
if a.activeWorkspace != nil && rootsReferToSameWorkspace(msg.Root, a.activeWorkspace.Root) {
4748
requestRoot = a.activeWorkspace.Root
49+
requestFull = true
4850
if a.gitStatus != nil {
4951
a.gitStatus.Invalidate(requestRoot)
5052
}
5153
a.dashboard.InvalidateStatus(requestRoot)
5254
}
55+
statusCmd := a.requestGitStatus(requestRoot)
56+
if requestFull {
57+
statusCmd = a.requestGitStatusFull(requestRoot)
58+
}
5359
return []tea.Cmd{
54-
a.requestGitStatus(requestRoot),
60+
statusCmd,
5561
a.startFileWatcher(),
5662
}
5763
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/andyrewlee/amux/internal/data"
8+
"github.com/andyrewlee/amux/internal/git"
9+
"github.com/andyrewlee/amux/internal/messages"
10+
"github.com/andyrewlee/amux/internal/ui/dashboard"
11+
)
12+
13+
type fileWatcherGitStatusStub struct {
14+
invalidateRoots []string
15+
refreshRoots []string
16+
refreshFastRoots []string
17+
cacheByRoot map[string]*git.StatusResult
18+
}
19+
20+
func (s *fileWatcherGitStatusStub) Run(context.Context) error { return nil }
21+
22+
func (s *fileWatcherGitStatusStub) GetCached(root string) *git.StatusResult {
23+
if s.cacheByRoot == nil {
24+
return nil
25+
}
26+
return s.cacheByRoot[root]
27+
}
28+
29+
func (s *fileWatcherGitStatusStub) UpdateCache(root string, status *git.StatusResult) {
30+
if s.cacheByRoot == nil {
31+
s.cacheByRoot = make(map[string]*git.StatusResult)
32+
}
33+
s.cacheByRoot[root] = status
34+
}
35+
36+
func (s *fileWatcherGitStatusStub) Invalidate(root string) {
37+
s.invalidateRoots = append(s.invalidateRoots, root)
38+
}
39+
40+
func (s *fileWatcherGitStatusStub) Refresh(root string) (*git.StatusResult, error) {
41+
s.refreshRoots = append(s.refreshRoots, root)
42+
return &git.StatusResult{HasLineStats: true}, nil
43+
}
44+
45+
func (s *fileWatcherGitStatusStub) RefreshFast(root string) (*git.StatusResult, error) {
46+
s.refreshFastRoots = append(s.refreshFastRoots, root)
47+
return &git.StatusResult{HasLineStats: false}, nil
48+
}
49+
50+
func TestHandleFileWatcherEvent_ActiveWorkspaceRequestsFullStatus(t *testing.T) {
51+
active := &data.Workspace{
52+
Repo: "/tmp/repo",
53+
Root: "/tmp/repo/ws-active",
54+
}
55+
stub := &fileWatcherGitStatusStub{}
56+
app := &App{
57+
gitStatus: stub,
58+
dashboard: dashboard.New(),
59+
activeWorkspace: active,
60+
dirtyWorkspaces: make(map[string]bool),
61+
creatingWorkspaceIDs: make(map[string]bool),
62+
}
63+
64+
cmds := app.handleFileWatcherEvent(messages.FileWatcherEvent{Root: active.Root})
65+
if len(cmds) != 2 {
66+
t.Fatalf("expected 2 commands, got %d", len(cmds))
67+
}
68+
if cmds[0] == nil {
69+
t.Fatal("expected status command")
70+
}
71+
msg := cmds[0]()
72+
result, ok := msg.(messages.GitStatusResult)
73+
if !ok {
74+
t.Fatalf("expected GitStatusResult, got %T", msg)
75+
}
76+
if result.Root != active.Root {
77+
t.Fatalf("expected root %q, got %q", active.Root, result.Root)
78+
}
79+
if result.Status == nil || !result.Status.HasLineStats {
80+
t.Fatalf("expected full status with line stats")
81+
}
82+
if len(stub.refreshRoots) != 1 {
83+
t.Fatalf("expected full refresh call, got %d", len(stub.refreshRoots))
84+
}
85+
if len(stub.refreshFastRoots) != 0 {
86+
t.Fatalf("expected no fast refresh call, got %d", len(stub.refreshFastRoots))
87+
}
88+
}
89+
90+
func TestHandleFileWatcherEvent_InactiveWorkspaceRequestsFastStatus(t *testing.T) {
91+
active := &data.Workspace{
92+
Repo: "/tmp/repo",
93+
Root: "/tmp/repo/ws-active",
94+
}
95+
otherRoot := "/tmp/repo/ws-other"
96+
stub := &fileWatcherGitStatusStub{}
97+
app := &App{
98+
gitStatus: stub,
99+
dashboard: dashboard.New(),
100+
activeWorkspace: active,
101+
dirtyWorkspaces: make(map[string]bool),
102+
creatingWorkspaceIDs: make(map[string]bool),
103+
}
104+
105+
cmds := app.handleFileWatcherEvent(messages.FileWatcherEvent{Root: otherRoot})
106+
if len(cmds) != 2 {
107+
t.Fatalf("expected 2 commands, got %d", len(cmds))
108+
}
109+
if cmds[0] == nil {
110+
t.Fatal("expected status command")
111+
}
112+
msg := cmds[0]()
113+
result, ok := msg.(messages.GitStatusResult)
114+
if !ok {
115+
t.Fatalf("expected GitStatusResult, got %T", msg)
116+
}
117+
if result.Root != otherRoot {
118+
t.Fatalf("expected root %q, got %q", otherRoot, result.Root)
119+
}
120+
if result.Status == nil || result.Status.HasLineStats {
121+
t.Fatalf("expected fast status without line stats")
122+
}
123+
if len(stub.refreshRoots) != 0 {
124+
t.Fatalf("expected no full refresh call, got %d", len(stub.refreshRoots))
125+
}
126+
if len(stub.refreshFastRoots) != 1 {
127+
t.Fatalf("expected one fast refresh call, got %d", len(stub.refreshFastRoots))
128+
}
129+
}
130+
131+
func TestHandleGitStatusTick_ActiveWorkspaceCacheMissRequestsFullStatus(t *testing.T) {
132+
active := &data.Workspace{
133+
Repo: "/tmp/repo",
134+
Root: "/tmp/repo/ws-active",
135+
}
136+
stub := &fileWatcherGitStatusStub{}
137+
app := &App{
138+
gitStatus: stub,
139+
dashboard: dashboard.New(),
140+
activeWorkspace: active,
141+
dirtyWorkspaces: make(map[string]bool),
142+
creatingWorkspaceIDs: make(map[string]bool),
143+
}
144+
145+
cmds := app.handleGitStatusTick()
146+
if len(cmds) != 2 {
147+
t.Fatalf("expected 2 commands, got %d", len(cmds))
148+
}
149+
if cmds[0] == nil {
150+
t.Fatal("expected status command")
151+
}
152+
msg := cmds[0]()
153+
result, ok := msg.(messages.GitStatusResult)
154+
if !ok {
155+
t.Fatalf("expected GitStatusResult, got %T", msg)
156+
}
157+
if result.Root != active.Root {
158+
t.Fatalf("expected root %q, got %q", active.Root, result.Root)
159+
}
160+
if result.Status == nil || !result.Status.HasLineStats {
161+
t.Fatalf("expected full status with line stats on cache miss")
162+
}
163+
if len(stub.refreshRoots) != 1 {
164+
t.Fatalf("expected full refresh call, got %d", len(stub.refreshRoots))
165+
}
166+
if len(stub.refreshFastRoots) != 0 {
167+
t.Fatalf("expected no fast refresh call, got %d", len(stub.refreshFastRoots))
168+
}
169+
}
170+
171+
func TestHandleGitStatusTick_ActiveWorkspaceCachedStatusSkipsRefresh(t *testing.T) {
172+
active := &data.Workspace{
173+
Repo: "/tmp/repo",
174+
Root: "/tmp/repo/ws-active",
175+
}
176+
stub := &fileWatcherGitStatusStub{
177+
cacheByRoot: map[string]*git.StatusResult{
178+
active.Root: {HasLineStats: true},
179+
},
180+
}
181+
app := &App{
182+
gitStatus: stub,
183+
dashboard: dashboard.New(),
184+
activeWorkspace: active,
185+
dirtyWorkspaces: make(map[string]bool),
186+
creatingWorkspaceIDs: make(map[string]bool),
187+
}
188+
189+
cmds := app.handleGitStatusTick()
190+
if len(cmds) != 2 {
191+
t.Fatalf("expected 2 commands, got %d", len(cmds))
192+
}
193+
if cmds[0] == nil {
194+
t.Fatal("expected status command")
195+
}
196+
msg := cmds[0]()
197+
result, ok := msg.(messages.GitStatusResult)
198+
if !ok {
199+
t.Fatalf("expected GitStatusResult, got %T", msg)
200+
}
201+
if result.Status == nil || !result.Status.HasLineStats {
202+
t.Fatalf("expected cached status with line stats")
203+
}
204+
if len(stub.refreshRoots) != 0 {
205+
t.Fatalf("expected no full refresh call when cached, got %d", len(stub.refreshRoots))
206+
}
207+
if len(stub.refreshFastRoots) != 0 {
208+
t.Fatalf("expected no fast refresh call when cached, got %d", len(stub.refreshFastRoots))
209+
}
210+
}

internal/app/app_operations.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,22 @@ func (a *App) rescanWorkspaces() tea.Cmd {
2525
return a.workspaceService.RescanWorkspaces()
2626
}
2727

28-
// requestGitStatus requests git status for a workspace (always fetches fresh).
28+
// requestGitStatus requests git status for a workspace using fast mode (skips line stats).
2929
func (a *App) requestGitStatus(root string) tea.Cmd {
30+
return func() tea.Msg {
31+
if a.gitStatus == nil {
32+
return messages.GitStatusResult{Root: root}
33+
}
34+
status, err := a.gitStatus.RefreshFast(root)
35+
if err == nil {
36+
a.gitStatus.UpdateCache(root, status)
37+
}
38+
return messages.GitStatusResult{Root: root, Status: status, Err: err}
39+
}
40+
}
41+
42+
// requestGitStatusFull requests git status with full line stats (for sidebar display).
43+
func (a *App) requestGitStatusFull(root string) tea.Cmd {
3044
return func() tea.Msg {
3145
if a.gitStatus == nil {
3246
return messages.GitStatusResult{Root: root}
@@ -40,14 +54,19 @@ func (a *App) requestGitStatus(root string) tea.Cmd {
4054
}
4155

4256
// requestGitStatusCached requests git status using cache if available.
43-
func (a *App) requestGitStatusCached(root string) tea.Cmd {
57+
// On cache miss, it falls back to full mode when fallbackToFull is true,
58+
// otherwise fast mode.
59+
func (a *App) requestGitStatusCached(root string, fallbackToFull bool) tea.Cmd {
4460
if a.gitStatus != nil {
4561
if cached := a.gitStatus.GetCached(root); cached != nil {
4662
return func() tea.Msg {
4763
return messages.GitStatusResult{Root: root, Status: cached}
4864
}
4965
}
5066
}
67+
if fallbackToFull {
68+
return a.requestGitStatusFull(root)
69+
}
5170
return a.requestGitStatus(root)
5271
}
5372

0 commit comments

Comments
 (0)