fix: invalidate stale split ratio callbacks
- commit
- 7137fd10fe86097f230324272cd8171eda5cb602
- author
- brice · · 2026-07-15 07:26 UTC
- parent
- 6903d03
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +139 -8
M internal/ui/layout/split.go +34 -8
| @@ -23,6 +23,7 @@ type SplitView struct { | |||
| 23 | 23 | onRatioChanged func(ratio float64) | |
| 24 | 24 | pendingNotifyRatio float64 | |
| 25 | 25 | notifyDebounceTimer *time.Timer | |
| 26 | + | notifyGeneration uint64 | |
| 26 | 27 | tickCallbackID uint | |
| 27 | 28 | cleanedUp bool | |
| 28 | 29 | ||
| @@ -40,6 +41,9 @@ const notifyPositionDebounceDelay = 100 * time.Millisecond | |||
| 40 | 41 | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | |
| 41 | 42 | const splitAllocationSettledTolerance = 2 | |
| 42 | 43 | ||
| 44 | + | // idleAdd is a seam for scheduling work on GLib's main loop. | |
| 45 | + | var idleAdd = glib.IdleAdd | |
| 46 | + | ||
| 43 | 47 | func childAllocation(widget Widget) (int, int) { | |
| 44 | 48 | if widget == nil { | |
| 45 | 49 | return 0, 0 | |
| @@ -153,25 +157,42 @@ func NewSplitView( | |||
| 153 | 157 | ratio := clampRatio(float64(position) / float64(totalSize)) | |
| 154 | 158 | ||
| 155 | 159 | sv.mu.Lock() | |
| 160 | + | if sv.cleanedUp { | |
| 161 | + | sv.mu.Unlock() | |
| 162 | + | return | |
| 163 | + | } | |
| 156 | 164 | sv.ratio = ratio | |
| 157 | 165 | sv.pendingNotifyRatio = ratio | |
| 158 | - | onRatioChanged := sv.onRatioChanged | |
| 166 | + | sv.notifyGeneration++ | |
| 167 | + | generation := sv.notifyGeneration | |
| 159 | 168 | ||
| 160 | 169 | if sv.notifyDebounceTimer != nil { | |
| 161 | 170 | sv.notifyDebounceTimer.Stop() | |
| 162 | 171 | } | |
| 163 | 172 | sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() { | |
| 164 | - | if onRatioChanged == nil { | |
| 173 | + | sv.mu.RLock() | |
| 174 | + | valid := !sv.cleanedUp && generation == sv.notifyGeneration | |
| 175 | + | sv.mu.RUnlock() | |
| 176 | + | if !valid { | |
| 165 | 177 | return | |
| 166 | 178 | } | |
| 179 | + | ||
| 167 | 180 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 168 | 181 | sv.mu.RLock() | |
| 182 | + | if sv.cleanedUp || generation != sv.notifyGeneration { | |
| 183 | + | sv.mu.RUnlock() | |
| 184 | + | return false | |
| 185 | + | } | |
| 186 | + | onRatioChanged := sv.onRatioChanged | |
| 169 | 187 | pending := sv.pendingNotifyRatio | |
| 170 | 188 | sv.mu.RUnlock() | |
| 171 | - | onRatioChanged(pending) | |
| 189 | + | ||
| 190 | + | if onRatioChanged != nil { | |
| 191 | + | onRatioChanged(pending) | |
| 192 | + | } | |
| 172 | 193 | return false | |
| 173 | 194 | }) | |
| 174 | - | glib.IdleAdd(&cb, 0) | |
| 195 | + | idleAdd(&cb, 0) | |
| 175 | 196 | }) | |
| 176 | 197 | ||
| 177 | 198 | sv.mu.Unlock() | |
| @@ -219,18 +240,23 @@ func NewSplitView( | |||
| 219 | 240 | // tree rebuild and GTK teardown can both reach the same view. | |
| 220 | 241 | func (sv *SplitView) Cleanup() { | |
| 221 | 242 | sv.mu.Lock() | |
| 222 | - | defer sv.mu.Unlock() | |
| 223 | 243 | if sv.cleanedUp { | |
| 244 | + | sv.mu.Unlock() | |
| 224 | 245 | return | |
| 225 | 246 | } | |
| 226 | 247 | sv.cleanedUp = true | |
| 248 | + | sv.notifyGeneration++ | |
| 249 | + | sv.onRatioChanged = nil | |
| 227 | 250 | if sv.notifyDebounceTimer != nil { | |
| 228 | 251 | sv.notifyDebounceTimer.Stop() | |
| 229 | 252 | sv.notifyDebounceTimer = nil | |
| 230 | 253 | } | |
| 231 | - | if sv.tickCallbackID != 0 { | |
| 232 | - | sv.paned.RemoveTickCallback(sv.tickCallbackID) | |
| 233 | - | sv.tickCallbackID = 0 | |
| 254 | + | tickCallbackID := sv.tickCallbackID | |
| 255 | + | sv.tickCallbackID = 0 | |
| 256 | + | sv.mu.Unlock() | |
| 257 | + | ||
| 258 | + | if tickCallbackID != 0 { | |
| 259 | + | sv.paned.RemoveTickCallback(tickCallbackID) | |
| 234 | 260 | } | |
| 235 | 261 | } | |
| 236 | 262 | ||
| @@ -23,6 +23,7 @@ type SplitView struct { | |||||
| 23 | onRatioChanged func(ratio float64) | 23 | onRatioChanged func(ratio float64) | ||
| 24 | pendingNotifyRatio float64 | 24 | pendingNotifyRatio float64 | ||
| 25 | notifyDebounceTimer *time.Timer | 25 | notifyDebounceTimer *time.Timer | ||
| 26 | + | notifyGeneration uint64 | |||
| 26 | tickCallbackID uint | 27 | tickCallbackID uint | ||
| 27 | cleanedUp bool | 28 | cleanedUp bool | ||
| 28 | 29 | ||||
| @@ -40,6 +41,9 @@ const notifyPositionDebounceDelay = 100 * time.Millisecond | |||||
| 40 | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | 41 | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | ||
| 41 | const splitAllocationSettledTolerance = 2 | 42 | const splitAllocationSettledTolerance = 2 | ||
| 42 | 43 | ||||
| 44 | + | // idleAdd is a seam for scheduling work on GLib's main loop. | |||
| 45 | + | var idleAdd = glib.IdleAdd | |||
| 46 | + | ||||
| 43 | func childAllocation(widget Widget) (int, int) { | 47 | func childAllocation(widget Widget) (int, int) { | ||
| 44 | if widget == nil { | 48 | if widget == nil { | ||
| 45 | return 0, 0 | 49 | return 0, 0 | ||
| @@ -153,25 +157,42 @@ func NewSplitView( | |||||
| 153 | ratio := clampRatio(float64(position) / float64(totalSize)) | 157 | ratio := clampRatio(float64(position) / float64(totalSize)) | ||
| 154 | 158 | ||||
| 155 | sv.mu.Lock() | 159 | sv.mu.Lock() | ||
| 160 | + | if sv.cleanedUp { | |||
| 161 | + | sv.mu.Unlock() | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 156 | sv.ratio = ratio | 164 | sv.ratio = ratio | ||
| 157 | sv.pendingNotifyRatio = ratio | 165 | sv.pendingNotifyRatio = ratio | ||
| 158 | - | onRatioChanged := sv.onRatioChanged | 166 | + | sv.notifyGeneration++ |
| 167 | + | generation := sv.notifyGeneration | |||
| 159 | 168 | ||||
| 160 | if sv.notifyDebounceTimer != nil { | 169 | if sv.notifyDebounceTimer != nil { | ||
| 161 | sv.notifyDebounceTimer.Stop() | 170 | sv.notifyDebounceTimer.Stop() | ||
| 162 | } | 171 | } | ||
| 163 | sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() { | 172 | sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() { | ||
| 164 | - | if onRatioChanged == nil { | 173 | + | sv.mu.RLock() |
| 174 | + | valid := !sv.cleanedUp && generation == sv.notifyGeneration | |||
| 175 | + | sv.mu.RUnlock() | |||
| 176 | + | if !valid { | |||
| 165 | return | 177 | return | ||
| 166 | } | 178 | } | ||
| 179 | + | ||||
| 167 | cb := glib.SourceFunc(func(_ uintptr) bool { | 180 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 168 | sv.mu.RLock() | 181 | sv.mu.RLock() | ||
| 182 | + | if sv.cleanedUp || generation != sv.notifyGeneration { | |||
| 183 | + | sv.mu.RUnlock() | |||
| 184 | + | return false | |||
| 185 | + | } | |||
| 186 | + | onRatioChanged := sv.onRatioChanged | |||
| 169 | pending := sv.pendingNotifyRatio | 187 | pending := sv.pendingNotifyRatio | ||
| 170 | sv.mu.RUnlock() | 188 | sv.mu.RUnlock() | ||
| 171 | - | onRatioChanged(pending) | 189 | + | |
| 190 | + | if onRatioChanged != nil { | |||
| 191 | + | onRatioChanged(pending) | |||
| 192 | + | } | |||
| 172 | return false | 193 | return false | ||
| 173 | }) | 194 | }) | ||
| 174 | - | glib.IdleAdd(&cb, 0) | 195 | + | idleAdd(&cb, 0) |
| 175 | }) | 196 | }) | ||
| 176 | 197 | ||||
| 177 | sv.mu.Unlock() | 198 | sv.mu.Unlock() | ||
| @@ -219,18 +240,23 @@ func NewSplitView( | |||||
| 219 | // tree rebuild and GTK teardown can both reach the same view. | 240 | // tree rebuild and GTK teardown can both reach the same view. | ||
| 220 | func (sv *SplitView) Cleanup() { | 241 | func (sv *SplitView) Cleanup() { | ||
| 221 | sv.mu.Lock() | 242 | sv.mu.Lock() | ||
| 222 | - | defer sv.mu.Unlock() | |||
| 223 | if sv.cleanedUp { | 243 | if sv.cleanedUp { | ||
| 244 | + | sv.mu.Unlock() | |||
| 224 | return | 245 | return | ||
| 225 | } | 246 | } | ||
| 226 | sv.cleanedUp = true | 247 | sv.cleanedUp = true | ||
| 248 | + | sv.notifyGeneration++ | |||
| 249 | + | sv.onRatioChanged = nil | |||
| 227 | if sv.notifyDebounceTimer != nil { | 250 | if sv.notifyDebounceTimer != nil { | ||
| 228 | sv.notifyDebounceTimer.Stop() | 251 | sv.notifyDebounceTimer.Stop() | ||
| 229 | sv.notifyDebounceTimer = nil | 252 | sv.notifyDebounceTimer = nil | ||
| 230 | } | 253 | } | ||
| 231 | - | if sv.tickCallbackID != 0 { | 254 | + | tickCallbackID := sv.tickCallbackID |
| 232 | - | sv.paned.RemoveTickCallback(sv.tickCallbackID) | 255 | + | sv.tickCallbackID = 0 |
| 233 | - | sv.tickCallbackID = 0 | 256 | + | sv.mu.Unlock() |
| 257 | + | ||||
| 258 | + | if tickCallbackID != 0 { | |||
| 259 | + | sv.paned.RemoveTickCallback(tickCallbackID) | |||
| 234 | } | 260 | } | ||
| 235 | } | 261 | } | ||
| 236 | 262 | ||||
A internal/ui/layout/split_notify_cleanup_test.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync/atomic" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/puregotk/v4/glib" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // notifyingPaned only implements the SplitView interactions exercised below. | |
| 15 | + | // Embedding the interface supplies the unused methods. | |
| 16 | + | type notifyingPaned struct { | |
| 17 | + | PanedWidget | |
| 18 | + | position int | |
| 19 | + | notify func() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (p *notifyingPaned) SetResizeStartChild(bool) {} | |
| 23 | + | func (p *notifyingPaned) SetResizeEndChild(bool) {} | |
| 24 | + | func (p *notifyingPaned) SetVisible(bool) {} | |
| 25 | + | func (p *notifyingPaned) ConnectNotifyPosition(fn func()) uint { p.notify = fn; return 0 } | |
| 26 | + | func (p *notifyingPaned) GetAllocatedWidth() int { return 100 } | |
| 27 | + | func (p *notifyingPaned) GetPosition() int { return p.position } | |
| 28 | + | func (p *notifyingPaned) SetPosition(position int) { p.position = position } | |
| 29 | + | ||
| 30 | + | type notifyingFactory struct { | |
| 31 | + | WidgetFactory | |
| 32 | + | paned PanedWidget | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (f notifyingFactory) NewPaned(Orientation) PanedWidget { return f.paned } | |
| 36 | + | ||
| 37 | + | func newNotifyingSplitView(t *testing.T) (*SplitView, func(int)) { | |
| 38 | + | t.Helper() | |
| 39 | + | ||
| 40 | + | paned := ¬ifyingPaned{} | |
| 41 | + | sv := NewSplitView(context.Background(), notifyingFactory{paned: paned}, OrientationHorizontal, nil, nil, 0) | |
| 42 | + | sv.mu.Lock() | |
| 43 | + | sv.suppressNotifyUntil = time.Time{} | |
| 44 | + | sv.mu.Unlock() | |
| 45 | + | require.NotNil(t, paned.notify) | |
| 46 | + | return sv, func(nextPosition int) { | |
| 47 | + | paned.position = nextPosition | |
| 48 | + | paned.notify() | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func receiveQueuedIdle(t *testing.T, queued <-chan *glib.SourceFunc) *glib.SourceFunc { | |
| 53 | + | t.Helper() | |
| 54 | + | select { | |
| 55 | + | case source := <-queued: | |
| 56 | + | return source | |
| 57 | + | case <-time.After(time.Second): | |
| 58 | + | t.Fatal("timed out waiting for debounce to queue idle callback") | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) { | |
| 64 | + | queued := make(chan *glib.SourceFunc, 1) | |
| 65 | + | previousIdleAdd := idleAdd | |
| 66 | + | idleAdd = func(source *glib.SourceFunc, _ uintptr) uint { | |
| 67 | + | queued <- source | |
| 68 | + | return 1 | |
| 69 | + | } | |
| 70 | + | t.Cleanup(func() { idleAdd = previousIdleAdd }) | |
| 71 | + | ||
| 72 | + | sv, notify := newNotifyingSplitView(t) | |
| 73 | + | var calls atomic.Int32 | |
| 74 | + | sv.SetOnRatioChanged(func(float64) { calls.Add(1) }) | |
| 75 | + | ||
| 76 | + | notify(25) | |
| 77 | + | source := receiveQueuedIdle(t, queued) // Debounce has fired and queued this work. | |
| 78 | + | sv.Cleanup() | |
| 79 | + | (*source)(0) // Run the queued GLib closure after cleanup. | |
| 80 | + | ||
| 81 | + | require.Zero(t, calls.Load(), "cleanup must invalidate queued ratio callbacks") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) { | |
| 85 | + | queued := make(chan *glib.SourceFunc, 2) | |
| 86 | + | previousIdleAdd := idleAdd | |
| 87 | + | idleAdd = func(source *glib.SourceFunc, _ uintptr) uint { | |
| 88 | + | queued <- source | |
| 89 | + | return 1 | |
| 90 | + | } | |
| 91 | + | t.Cleanup(func() { idleAdd = previousIdleAdd }) | |
| 92 | + | ||
| 93 | + | sv, notify := newNotifyingSplitView(t) | |
| 94 | + | var ratios []float64 | |
| 95 | + | sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) }) | |
| 96 | + | ||
| 97 | + | notify(20) | |
| 98 | + | first := receiveQueuedIdle(t, queued) | |
| 99 | + | notify(80) | |
| 100 | + | second := receiveQueuedIdle(t, queued) | |
| 101 | + | (*first)(0) | |
| 102 | + | (*second)(0) | |
| 103 | + | ||
| 104 | + | require.Equal(t, []float64{0.8}, ratios) | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // notifyingPaned only implements the SplitView interactions exercised below. | |||
| 15 | + | // Embedding the interface supplies the unused methods. | |||
| 16 | + | type notifyingPaned struct { | |||
| 17 | + | PanedWidget | |||
| 18 | + | position int | |||
| 19 | + | notify func() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p *notifyingPaned) SetResizeStartChild(bool) {} | |||
| 23 | + | func (p *notifyingPaned) SetResizeEndChild(bool) {} | |||
| 24 | + | func (p *notifyingPaned) SetVisible(bool) {} | |||
| 25 | + | func (p *notifyingPaned) ConnectNotifyPosition(fn func()) uint { p.notify = fn; return 0 } | |||
| 26 | + | func (p *notifyingPaned) GetAllocatedWidth() int { return 100 } | |||
| 27 | + | func (p *notifyingPaned) GetPosition() int { return p.position } | |||
| 28 | + | func (p *notifyingPaned) SetPosition(position int) { p.position = position } | |||
| 29 | + | ||||
| 30 | + | type notifyingFactory struct { | |||
| 31 | + | WidgetFactory | |||
| 32 | + | paned PanedWidget | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (f notifyingFactory) NewPaned(Orientation) PanedWidget { return f.paned } | |||
| 36 | + | ||||
| 37 | + | func newNotifyingSplitView(t *testing.T) (*SplitView, func(int)) { | |||
| 38 | + | t.Helper() | |||
| 39 | + | ||||
| 40 | + | paned := ¬ifyingPaned{} | |||
| 41 | + | sv := NewSplitView(context.Background(), notifyingFactory{paned: paned}, OrientationHorizontal, nil, nil, 0) | |||
| 42 | + | sv.mu.Lock() | |||
| 43 | + | sv.suppressNotifyUntil = time.Time{} | |||
| 44 | + | sv.mu.Unlock() | |||
| 45 | + | require.NotNil(t, paned.notify) | |||
| 46 | + | return sv, func(nextPosition int) { | |||
| 47 | + | paned.position = nextPosition | |||
| 48 | + | paned.notify() | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func receiveQueuedIdle(t *testing.T, queued <-chan *glib.SourceFunc) *glib.SourceFunc { | |||
| 53 | + | t.Helper() | |||
| 54 | + | select { | |||
| 55 | + | case source := <-queued: | |||
| 56 | + | return source | |||
| 57 | + | case <-time.After(time.Second): | |||
| 58 | + | t.Fatal("timed out waiting for debounce to queue idle callback") | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) { | |||
| 64 | + | queued := make(chan *glib.SourceFunc, 1) | |||
| 65 | + | previousIdleAdd := idleAdd | |||
| 66 | + | idleAdd = func(source *glib.SourceFunc, _ uintptr) uint { | |||
| 67 | + | queued <- source | |||
| 68 | + | return 1 | |||
| 69 | + | } | |||
| 70 | + | t.Cleanup(func() { idleAdd = previousIdleAdd }) | |||
| 71 | + | ||||
| 72 | + | sv, notify := newNotifyingSplitView(t) | |||
| 73 | + | var calls atomic.Int32 | |||
| 74 | + | sv.SetOnRatioChanged(func(float64) { calls.Add(1) }) | |||
| 75 | + | ||||
| 76 | + | notify(25) | |||
| 77 | + | source := receiveQueuedIdle(t, queued) // Debounce has fired and queued this work. | |||
| 78 | + | sv.Cleanup() | |||
| 79 | + | (*source)(0) // Run the queued GLib closure after cleanup. | |||
| 80 | + | ||||
| 81 | + | require.Zero(t, calls.Load(), "cleanup must invalidate queued ratio callbacks") | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) { | |||
| 85 | + | queued := make(chan *glib.SourceFunc, 2) | |||
| 86 | + | previousIdleAdd := idleAdd | |||
| 87 | + | idleAdd = func(source *glib.SourceFunc, _ uintptr) uint { | |||
| 88 | + | queued <- source | |||
| 89 | + | return 1 | |||
| 90 | + | } | |||
| 91 | + | t.Cleanup(func() { idleAdd = previousIdleAdd }) | |||
| 92 | + | ||||
| 93 | + | sv, notify := newNotifyingSplitView(t) | |||
| 94 | + | var ratios []float64 | |||
| 95 | + | sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) }) | |||
| 96 | + | ||||
| 97 | + | notify(20) | |||
| 98 | + | first := receiveQueuedIdle(t, queued) | |||
| 99 | + | notify(80) | |||
| 100 | + | second := receiveQueuedIdle(t, queued) | |||
| 101 | + | (*first)(0) | |||
| 102 | + | (*second)(0) | |||
| 103 | + | ||||
| 104 | + | require.Equal(t, []float64{0.8}, ratios) | |||
| 105 | + | } | |||