Skip to content

Commit f27a57c

Browse files
committed
add interfaces and tests
1 parent 4e4c532 commit f27a57c

File tree

2 files changed

+310
-0
lines changed

2 files changed

+310
-0
lines changed

cmd/goose/deadlock_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package main
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestConcurrentMenuOperations tests that concurrent menu operations don't cause deadlocks
10+
func TestConcurrentMenuOperations(t *testing.T) {
11+
app := &App{
12+
mu: sync.RWMutex{},
13+
stateManager: NewPRStateManager(time.Now()),
14+
hiddenOrgs: make(map[string]bool),
15+
seenOrgs: make(map[string]bool),
16+
blockedPRTimes: make(map[string]time.Time),
17+
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
18+
systrayInterface: &MockSystray{},
19+
incoming: []PR{
20+
{Repository: "org1/repo1", Number: 1, Title: "Fix bug", URL: "https://github.com/org1/repo1/pull/1"},
21+
},
22+
outgoing: []PR{
23+
{Repository: "org2/repo2", Number: 2, Title: "Add feature", URL: "https://github.com/org2/repo2/pull/2"},
24+
},
25+
}
26+
27+
// Use a WaitGroup to coordinate goroutines
28+
var wg sync.WaitGroup
29+
30+
// Use a channel to detect if we've deadlocked
31+
done := make(chan bool, 1)
32+
33+
// Number of concurrent operations to test
34+
concurrentOps := 10
35+
36+
wg.Add(concurrentOps * 3) // 3 types of operations
37+
38+
// Start a goroutine that will signal completion
39+
go func() {
40+
wg.Wait()
41+
done <- true
42+
}()
43+
44+
// Simulate concurrent menu clicks (write lock operations)
45+
for i := 0; i < concurrentOps; i++ {
46+
go func() {
47+
defer wg.Done()
48+
49+
// This simulates the click handler storing menu titles
50+
menuTitles := app.generateMenuTitles()
51+
app.mu.Lock()
52+
app.lastMenuTitles = menuTitles
53+
app.mu.Unlock()
54+
}()
55+
}
56+
57+
// Simulate concurrent menu generation (read lock operations)
58+
for i := 0; i < concurrentOps; i++ {
59+
go func() {
60+
defer wg.Done()
61+
62+
// This simulates generating menu titles
63+
_ = app.generateMenuTitles()
64+
}()
65+
}
66+
67+
// Simulate concurrent PR updates (write lock operations)
68+
for i := 0; i < concurrentOps; i++ {
69+
go func(iteration int) {
70+
defer wg.Done()
71+
72+
app.mu.Lock()
73+
// Simulate updating PR data
74+
if iteration%2 == 0 {
75+
app.incoming = append(app.incoming, PR{
76+
Repository: "test/repo",
77+
Number: iteration,
78+
Title: "Test PR",
79+
URL: "https://github.com/test/repo/pull/1",
80+
})
81+
}
82+
app.mu.Unlock()
83+
}(i)
84+
}
85+
86+
// Wait for operations to complete or timeout
87+
select {
88+
case <-done:
89+
// Success - all operations completed without deadlock
90+
t.Log("All concurrent operations completed successfully")
91+
case <-time.After(5 * time.Second):
92+
t.Fatal("Deadlock detected: operations did not complete within 5 seconds")
93+
}
94+
}
95+
96+
// TestMenuClickDeadlockScenario specifically tests the deadlock scenario that was fixed
97+
func TestMenuClickDeadlockScenario(t *testing.T) {
98+
app := &App{
99+
mu: sync.RWMutex{},
100+
stateManager: NewPRStateManager(time.Now()),
101+
hiddenOrgs: make(map[string]bool),
102+
seenOrgs: make(map[string]bool),
103+
blockedPRTimes: make(map[string]time.Time),
104+
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
105+
systrayInterface: &MockSystray{},
106+
incoming: []PR{
107+
{Repository: "org1/repo1", Number: 1, Title: "Test PR", URL: "https://github.com/org1/repo1/pull/1"},
108+
},
109+
}
110+
111+
// This exact sequence previously caused a deadlock:
112+
// 1. Click handler acquires write lock
113+
// 2. Click handler calls generateMenuTitles while holding lock
114+
// 3. generateMenuTitles tries to acquire read lock
115+
// 4. Deadlock!
116+
117+
// The fix ensures we don't hold the lock when calling generateMenuTitles
118+
done := make(chan bool, 1)
119+
120+
go func() {
121+
// Simulate the fixed click handler behavior
122+
menuTitles := app.generateMenuTitles() // Called WITHOUT holding lock
123+
app.mu.Lock()
124+
app.lastMenuTitles = menuTitles
125+
app.mu.Unlock()
126+
done <- true
127+
}()
128+
129+
select {
130+
case <-done:
131+
t.Log("Click handler completed without deadlock")
132+
case <-time.After(1 * time.Second):
133+
t.Fatal("Click handler deadlocked")
134+
}
135+
}
136+
137+
// TestRapidMenuClicks tests that rapid menu clicks don't cause issues
138+
func TestRapidMenuClicks(t *testing.T) {
139+
app := &App{
140+
mu: sync.RWMutex{},
141+
stateManager: NewPRStateManager(time.Now()),
142+
hiddenOrgs: make(map[string]bool),
143+
seenOrgs: make(map[string]bool),
144+
blockedPRTimes: make(map[string]time.Time),
145+
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
146+
systrayInterface: &MockSystray{},
147+
lastSearchAttempt: time.Now().Add(-15 * time.Second), // Allow first click
148+
incoming: []PR{
149+
{Repository: "org1/repo1", Number: 1, Title: "Test", URL: "https://github.com/org1/repo1/pull/1"},
150+
},
151+
}
152+
153+
// Simulate 20 rapid clicks
154+
clickCount := 20
155+
successfulClicks := 0
156+
157+
for i := 0; i < clickCount; i++ {
158+
// Check if enough time has passed for rate limiting
159+
app.mu.RLock()
160+
timeSince := time.Since(app.lastSearchAttempt)
161+
app.mu.RUnlock()
162+
163+
if timeSince >= minUpdateInterval {
164+
// This click would trigger a refresh
165+
app.mu.Lock()
166+
app.lastSearchAttempt = time.Now()
167+
app.mu.Unlock()
168+
successfulClicks++
169+
170+
// Also update menu titles as the real handler would
171+
menuTitles := app.generateMenuTitles()
172+
app.mu.Lock()
173+
app.lastMenuTitles = menuTitles
174+
app.mu.Unlock()
175+
}
176+
177+
// Small delay between clicks to simulate human clicking
178+
time.Sleep(10 * time.Millisecond)
179+
}
180+
181+
// Due to rate limiting, we should only have 1-2 successful clicks
182+
if successfulClicks > 3 {
183+
t.Errorf("Rate limiting not working: %d clicks succeeded out of %d rapid clicks", successfulClicks, clickCount)
184+
}
185+
186+
t.Logf("Rate limiting working correctly: %d clicks succeeded out of %d rapid clicks", successfulClicks, clickCount)
187+
}

cmd/goose/menu_item_interface.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package main
2+
3+
import "github.com/energye/systray"
4+
5+
// MenuItem is an interface for menu items that can be implemented by both
6+
// real systray menu items and mock menu items for testing.
7+
type MenuItem interface {
8+
Disable()
9+
Enable()
10+
Check()
11+
Uncheck()
12+
SetTitle(string)
13+
SetTooltip(string)
14+
Click(func())
15+
AddSubMenuItem(title, tooltip string) MenuItem
16+
}
17+
18+
// RealMenuItem wraps a real systray.MenuItem to implement our MenuItem interface.
19+
type RealMenuItem struct {
20+
*systray.MenuItem
21+
}
22+
23+
// Ensure RealMenuItem implements MenuItem interface.
24+
var _ MenuItem = (*RealMenuItem)(nil)
25+
26+
// Disable disables the menu item.
27+
func (r *RealMenuItem) Disable() {
28+
r.MenuItem.Disable()
29+
}
30+
31+
// Enable enables the menu item.
32+
func (r *RealMenuItem) Enable() {
33+
r.MenuItem.Enable()
34+
}
35+
36+
// Check checks the menu item.
37+
func (r *RealMenuItem) Check() {
38+
r.MenuItem.Check()
39+
}
40+
41+
// Uncheck unchecks the menu item.
42+
func (r *RealMenuItem) Uncheck() {
43+
r.MenuItem.Uncheck()
44+
}
45+
46+
// SetTitle sets the menu item title.
47+
func (r *RealMenuItem) SetTitle(title string) {
48+
r.MenuItem.SetTitle(title)
49+
}
50+
51+
// SetTooltip sets the menu item tooltip.
52+
func (r *RealMenuItem) SetTooltip(tooltip string) {
53+
r.MenuItem.SetTooltip(tooltip)
54+
}
55+
56+
// Click sets the click handler.
57+
func (r *RealMenuItem) Click(handler func()) {
58+
r.MenuItem.Click(handler)
59+
}
60+
61+
// AddSubMenuItem adds a sub menu item and returns it wrapped in our interface.
62+
func (r *RealMenuItem) AddSubMenuItem(title, tooltip string) MenuItem {
63+
subItem := r.MenuItem.AddSubMenuItem(title, tooltip)
64+
return &RealMenuItem{MenuItem: subItem}
65+
}
66+
67+
// MockMenuItem implements MenuItem for testing without calling systray functions.
68+
type MockMenuItem struct {
69+
title string
70+
tooltip string
71+
disabled bool
72+
checked bool
73+
clickHandler func()
74+
subItems []MenuItem
75+
}
76+
77+
// Ensure MockMenuItem implements MenuItem interface.
78+
var _ MenuItem = (*MockMenuItem)(nil)
79+
80+
// Disable marks the item as disabled.
81+
func (m *MockMenuItem) Disable() {
82+
m.disabled = true
83+
}
84+
85+
// Enable marks the item as enabled.
86+
func (m *MockMenuItem) Enable() {
87+
m.disabled = false
88+
}
89+
90+
// Check marks the item as checked.
91+
func (m *MockMenuItem) Check() {
92+
m.checked = true
93+
}
94+
95+
// Uncheck marks the item as unchecked.
96+
func (m *MockMenuItem) Uncheck() {
97+
m.checked = false
98+
}
99+
100+
// SetTitle sets the title.
101+
func (m *MockMenuItem) SetTitle(title string) {
102+
m.title = title
103+
}
104+
105+
// SetTooltip sets the tooltip.
106+
func (m *MockMenuItem) SetTooltip(tooltip string) {
107+
m.tooltip = tooltip
108+
}
109+
110+
// Click sets the click handler.
111+
func (m *MockMenuItem) Click(handler func()) {
112+
m.clickHandler = handler
113+
}
114+
115+
// AddSubMenuItem adds a sub menu item (mock).
116+
func (m *MockMenuItem) AddSubMenuItem(title, tooltip string) MenuItem {
117+
subItem := &MockMenuItem{
118+
title: title,
119+
tooltip: tooltip,
120+
}
121+
m.subItems = append(m.subItems, subItem)
122+
return subItem
123+
}

0 commit comments

Comments
 (0)