fix(desktop): the status icon ends up on the fold the tracker actually holds

Two panes changing at once take their folds under the lock and report outside
it, so the two reports could reach the icon in the opposite order. The one that
lost left the icon describing a state the tracker had already moved past, and
nothing corrected it: the fold reports only what changed, so a wrong paint
latched until the next real change. A pane running a fifteen-minute turn is
fifteen minutes of a tray that says nothing is running.

Painting is now serialised and describes the fold as it stands when it paints,
so a report that lost the race repaints the truth instead of overwriting it.

The test fails on the old code with the reported symptom exactly: the icon
shows Working:0 while the tracker holds Working:1.

Reported in #9448.
This commit is contained in:
yhh committed 2026-08-26 06:05:46 -07:00
1 parent 4d320f7cd7
commit db030ad097
2 files changed
+64 -9

No files matched your search

+27 -9
View File
@@ -48,13 +48,16 @@ func (s State) Mood() Mood {
func (s State) Busy() bool { return s.Working > 0 || s.Attention > 0 || s.Jobs > 0 }
// Tracker folds what it is shown. Panes emit from their own goroutines, so it
// is safe for concurrent use, and it never calls back while holding its lock.
// is safe for concurrent use, and it never calls back while holding the fold's
// lock — a callback is free to ask it anything except to publish.
type Tracker struct {
mu sync.Mutex
panes map[string]*pane
jobs int
last State
changed func(State)
// paint serialises reports. A callback must not publish from inside itself.
paint sync.Mutex
}
type pane struct {
@@ -79,11 +82,8 @@ func (t *Tracker) SetOnChange(changed func(State)) {
}
t.mu.Lock()
t.changed = changed
state := t.stateLocked()
t.mu.Unlock()
if changed != nil {
changed(state)
}
t.report(changed)
}
// Watch returns inner wrapped so this pane's events reach the fold on the way
@@ -151,8 +151,8 @@ func (t *Tracker) stateLocked() State {
return out
}
// publish reports a change once, outside the lock: a callback that paints an
// icon has no business running inside the fold every pane emits into.
// publish reports a change once, outside the fold's lock: a callback that
// paints an icon has no business running inside the fold every pane emits into.
func (t *Tracker) publish() {
t.mu.Lock()
next := t.stateLocked()
@@ -160,9 +160,27 @@ func (t *Tracker) publish() {
t.last = next
report := t.changed
t.mu.Unlock()
if changed && report != nil {
report(next)
if !changed {
return
}
t.report(report)
}
// report paints, serialised and always describing the fold as it stands now.
// Two panes changing at once take their folds under the lock and report outside
// it, so the reports can arrive in the opposite order; the one that lost would
// leave the icon on a state the tracker has already moved past, and nothing
// would correct it, because the fold reports only what changed.
func (t *Tracker) report(changed func(State)) {
if changed == nil {
return
}
t.paint.Lock()
defer t.paint.Unlock()
t.mu.Lock()
now := t.stateLocked()
t.mu.Unlock()
changed(now)
}
type paneSink struct {
+37
View File
@@ -1,6 +1,7 @@
package traystate
import (
"runtime"
"sync"
"testing"
@@ -125,3 +126,39 @@ func TestConcurrentPanesFoldSafely(t *testing.T) {
t.Fatalf("state = %+v, want eight quiet panes", got)
}
}
// The icon has to end up describing the fold the tracker holds. Folds are taken
// under the lock and painted outside it, so two panes changing at once can
// reach the callback in the opposite order, and the loser strands the icon on a
// state already moved past — with nothing to correct it, since the fold reports
// only what changed.
func TestTheLastPaintDescribesTheCurrentFold(t *testing.T) {
for attempt := range 200 {
var mu sync.Mutex
var last State
tracker := New(func(s State) {
// The real callback paints through a platform call: not fast, and
// that is what opens the window between fold and paint.
runtime.Gosched()
mu.Lock()
last = s
mu.Unlock()
})
a := tracker.Watch("a", event.Discard)
b := tracker.Watch("b", event.Discard)
emit(a, event.TurnStarted)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); emit(a, event.TurnDone) }()
go func() { defer wg.Done(); emit(b, event.TurnStarted) }()
wg.Wait()
mu.Lock()
painted := last
mu.Unlock()
if got := tracker.State(); painted != got {
t.Fatalf("attempt %d: icon shows %+v, tracker holds %+v", attempt, painted, got)
}
}
}