Skip to content

Commit ffead18

Browse files
Dumbrisclaude
andcommitted
Fix: Configuration auto-refresh in-memory sync and SSE event integration
This PR fixes critical bugs in the configuration auto-refresh feature that prevented real-time updates in the Web UI Configuration page. ## Changes ### Backend (internal/runtime/) - **lifecycle.go**: Fixed in-memory config sync bug where configSvc path didn't update r.cfg.Servers - Moved mutex-protected update outside if/else to apply to both configSvc and legacy paths - Added comprehensive debug logging (before/after disk save, old vs new server counts) - Added emitConfigSaved() call to broadcast config.saved events - **event_bus.go**: Added emitConfigSaved() method for SSE integration - **events.go**: Added EventTypeConfigSaved constant - **event_bus_test.go**: Unit tests for event bus subscription and emission ### Frontend (frontend/src/) - **stores/system.ts**: Added SSE listener for config.saved events - Dispatches CustomEvent 'mcpproxy:config-saved' to notify Settings view - **views/Settings.vue**: Added event listener and cleanup - Calls loadConfig() when config.saved event received - Proper onMounted/onUnmounted lifecycle hooks ### Testing (internal/) - **testutil/binary.go**: Added GetConfigPath() helper for E2E tests - **server/e2e_config_auto_refresh_test.go**: Integration test structure for config auto-refresh ## Bug Fix Details **Problem**: When adding servers via Web UI, the configuration file was updated but the /api/v1/config endpoint returned stale data, causing Monaco editor to not show new servers. **Root Cause**: In SaveConfiguration(), the in-memory config update only happened in the legacy else branch, not in the configSvc path. **Solution**: Moved the in-memory update outside the conditional to ensure both paths update r.cfg.Servers. ## Verification Tested with Playwright E2E: - ✅ Server added via Web UI appears in config file - ✅ config.saved SSE event received in browser console - ✅ Monaco editor automatically reloads and displays new server - ✅ API endpoint returns fresh data immediately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ca7e44c commit ffead18

8 files changed

Lines changed: 404 additions & 6 deletions

File tree

frontend/src/stores/system.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,19 @@ export const useSystemStore = defineStore('system', () => {
140140
}
141141
})
142142

143+
// Listen for config.saved events to notify Configuration page
144+
es.addEventListener('config.saved', (event) => {
145+
try {
146+
const data = JSON.parse(event.data)
147+
console.log('SSE config.saved event received:', data)
148+
149+
// Dispatch event for Configuration page to refresh
150+
window.dispatchEvent(new CustomEvent('mcpproxy:config-saved', { detail: data }))
151+
} catch (error) {
152+
console.error('Failed to parse SSE config.saved event:', error)
153+
}
154+
})
155+
143156
es.onerror = (event) => {
144157
connected.value = false
145158
console.error('EventSource error occurred:', event)

frontend/src/views/Settings.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
</template>
113113

114114
<script setup lang="ts">
115-
import { ref, computed, onMounted } from 'vue'
115+
import { ref, computed, onMounted, onUnmounted } from 'vue'
116116
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
117117
import { useServersStore } from '@/stores/servers'
118118
import CollapsibleHintsPanel from '@/components/CollapsibleHintsPanel.vue'
@@ -330,8 +330,25 @@ const settingsHints = computed<Hint[]>(() => {
330330
]
331331
})
332332
333+
// Configuration reload event listener
334+
function handleConfigSaved(event: Event) {
335+
const customEvent = event as CustomEvent
336+
console.log('Configuration saved event received, reloading config:', customEvent.detail)
337+
338+
// Reload configuration to show updated servers
339+
loadConfig()
340+
}
341+
333342
// Initialize component
334343
onMounted(() => {
335344
loadConfig() // Load configuration when component mounts
345+
346+
// Listen for config.saved events from SSE
347+
window.addEventListener('mcpproxy:config-saved', handleConfigSaved)
348+
})
349+
350+
onUnmounted(() => {
351+
// Clean up event listener
352+
window.removeEventListener('mcpproxy:config-saved', handleConfigSaved)
336353
})
337354
</script>

internal/runtime/event_bus.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@ func (r *Runtime) emitConfigReloaded(path string) {
4646
payload := map[string]any{"path": path}
4747
r.publishEvent(newEvent(EventTypeConfigReloaded, payload))
4848
}
49+
50+
func (r *Runtime) emitConfigSaved(path string) {
51+
payload := map[string]any{"path": path}
52+
r.publishEvent(newEvent(EventTypeConfigSaved, payload))
53+
}

internal/runtime/event_bus_test.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package runtime
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"go.uber.org/zap"
11+
)
12+
13+
func TestEventBus_ConfigSavedEvent(t *testing.T) {
14+
// Create logger
15+
logger, err := zap.NewDevelopment()
16+
require.NoError(t, err)
17+
defer logger.Sync()
18+
19+
// Create minimal runtime for testing event bus
20+
rt := &Runtime{
21+
logger: logger,
22+
eventSubs: make(map[chan Event]struct{}),
23+
}
24+
25+
// Subscribe to events
26+
eventChan := rt.SubscribeEvents()
27+
defer rt.UnsubscribeEvents(eventChan)
28+
29+
// Create a done channel to signal when we receive the event
30+
done := make(chan Event, 1)
31+
32+
// Listen for config.saved event in background
33+
go func() {
34+
select {
35+
case evt := <-eventChan:
36+
if evt.Type == EventTypeConfigSaved {
37+
done <- evt
38+
}
39+
case <-time.After(2 * time.Second):
40+
t.Log("Timeout waiting for config.saved event")
41+
}
42+
}()
43+
44+
// Trigger emitConfigSaved
45+
testPath := "/test/config.json"
46+
rt.emitConfigSaved(testPath)
47+
48+
// Wait for event
49+
select {
50+
case evt := <-done:
51+
assert.Equal(t, EventTypeConfigSaved, evt.Type, "Event type should be config.saved")
52+
assert.NotNil(t, evt.Payload, "Event payload should not be nil")
53+
assert.Equal(t, testPath, evt.Payload["path"], "Event payload should contain config path")
54+
assert.NotZero(t, evt.Timestamp, "Event should have a timestamp")
55+
case <-time.After(2 * time.Second):
56+
t.Fatal("Did not receive config.saved event within timeout")
57+
}
58+
}
59+
60+
func TestEventBus_ConfigReloadedEvent(t *testing.T) {
61+
// Create logger
62+
logger, err := zap.NewDevelopment()
63+
require.NoError(t, err)
64+
defer logger.Sync()
65+
66+
rt := &Runtime{
67+
logger: logger,
68+
eventSubs: make(map[chan Event]struct{}),
69+
}
70+
71+
// Subscribe to events
72+
eventChan := rt.SubscribeEvents()
73+
defer rt.UnsubscribeEvents(eventChan)
74+
75+
done := make(chan Event, 1)
76+
77+
// Listen for config.reloaded event
78+
go func() {
79+
select {
80+
case evt := <-eventChan:
81+
if evt.Type == EventTypeConfigReloaded {
82+
done <- evt
83+
}
84+
case <-time.After(2 * time.Second):
85+
t.Log("Timeout waiting for config.reloaded event")
86+
}
87+
}()
88+
89+
// Trigger emitConfigReloaded
90+
testPath := "/test/config.json"
91+
rt.emitConfigReloaded(testPath)
92+
93+
// Wait for event
94+
select {
95+
case evt := <-done:
96+
assert.Equal(t, EventTypeConfigReloaded, evt.Type, "Event type should be config.reloaded")
97+
assert.NotNil(t, evt.Payload, "Event payload should not be nil")
98+
assert.Equal(t, testPath, evt.Payload["path"], "Event payload should contain config path")
99+
assert.NotZero(t, evt.Timestamp, "Event should have a timestamp")
100+
case <-time.After(2 * time.Second):
101+
t.Fatal("Did not receive config.reloaded event within timeout")
102+
}
103+
}
104+
105+
func TestEventBus_ServersChangedEvent(t *testing.T) {
106+
logger, err := zap.NewDevelopment()
107+
require.NoError(t, err)
108+
defer logger.Sync()
109+
110+
rt := &Runtime{
111+
logger: logger,
112+
eventSubs: make(map[chan Event]struct{}),
113+
}
114+
115+
// Subscribe to events
116+
eventChan := rt.SubscribeEvents()
117+
defer rt.UnsubscribeEvents(eventChan)
118+
119+
done := make(chan Event, 1)
120+
121+
// Listen for servers.changed event
122+
go func() {
123+
select {
124+
case evt := <-eventChan:
125+
if evt.Type == EventTypeServersChanged {
126+
done <- evt
127+
}
128+
case <-time.After(2 * time.Second):
129+
t.Log("Timeout waiting for servers.changed event")
130+
}
131+
}()
132+
133+
// Trigger emitServersChanged with custom payload
134+
extra := map[string]any{
135+
"server": "test-server",
136+
"enabled": true,
137+
}
138+
rt.emitServersChanged("test_reason", extra)
139+
140+
// Wait for event
141+
select {
142+
case evt := <-done:
143+
assert.Equal(t, EventTypeServersChanged, evt.Type, "Event type should be servers.changed")
144+
assert.NotNil(t, evt.Payload, "Event payload should not be nil")
145+
assert.Equal(t, "test_reason", evt.Payload["reason"], "Event should contain reason")
146+
assert.Equal(t, "test-server", evt.Payload["server"], "Event should contain server name")
147+
assert.Equal(t, true, evt.Payload["enabled"], "Event should contain enabled flag")
148+
assert.NotZero(t, evt.Timestamp, "Event should have a timestamp")
149+
case <-time.After(2 * time.Second):
150+
t.Fatal("Did not receive servers.changed event within timeout")
151+
}
152+
}
153+
154+
func TestEventBus_MultipleSubscribers(t *testing.T) {
155+
logger, err := zap.NewDevelopment()
156+
require.NoError(t, err)
157+
defer logger.Sync()
158+
159+
rt := &Runtime{
160+
logger: logger,
161+
eventSubs: make(map[chan Event]struct{}),
162+
}
163+
164+
// Create multiple subscribers
165+
sub1 := rt.SubscribeEvents()
166+
sub2 := rt.SubscribeEvents()
167+
sub3 := rt.SubscribeEvents()
168+
169+
defer rt.UnsubscribeEvents(sub1)
170+
defer rt.UnsubscribeEvents(sub2)
171+
defer rt.UnsubscribeEvents(sub3)
172+
173+
// Create done channels for each subscriber
174+
done1 := make(chan Event, 1)
175+
done2 := make(chan Event, 1)
176+
done3 := make(chan Event, 1)
177+
178+
// Listen on all subscribers
179+
go func() {
180+
select {
181+
case evt := <-sub1:
182+
done1 <- evt
183+
case <-time.After(2 * time.Second):
184+
t.Log("Subscriber 1 timeout")
185+
}
186+
}()
187+
188+
go func() {
189+
select {
190+
case evt := <-sub2:
191+
done2 <- evt
192+
case <-time.After(2 * time.Second):
193+
t.Log("Subscriber 2 timeout")
194+
}
195+
}()
196+
197+
go func() {
198+
select {
199+
case evt := <-sub3:
200+
done3 <- evt
201+
case <-time.After(2 * time.Second):
202+
t.Log("Subscriber 3 timeout")
203+
}
204+
}()
205+
206+
// Emit single event
207+
rt.emitConfigSaved("/test/path")
208+
209+
// All subscribers should receive the event
210+
timeout := time.After(2 * time.Second)
211+
receivedCount := 0
212+
213+
for i := 0; i < 3; i++ {
214+
select {
215+
case evt := <-done1:
216+
assert.Equal(t, EventTypeConfigSaved, evt.Type)
217+
receivedCount++
218+
case evt := <-done2:
219+
assert.Equal(t, EventTypeConfigSaved, evt.Type)
220+
receivedCount++
221+
case evt := <-done3:
222+
assert.Equal(t, EventTypeConfigSaved, evt.Type)
223+
receivedCount++
224+
case <-timeout:
225+
t.Fatalf("Only %d of 3 subscribers received the event", receivedCount)
226+
}
227+
}
228+
229+
assert.Equal(t, 3, receivedCount, "All 3 subscribers should have received the event")
230+
}
231+
232+
func TestEventBus_ConcurrentPublish(t *testing.T) {
233+
logger, err := zap.NewDevelopment()
234+
require.NoError(t, err)
235+
defer logger.Sync()
236+
237+
rt := &Runtime{
238+
logger: logger,
239+
eventSubs: make(map[chan Event]struct{}),
240+
}
241+
242+
// Create subscriber
243+
eventChan := rt.SubscribeEvents()
244+
defer rt.UnsubscribeEvents(eventChan)
245+
246+
// Count received events
247+
receivedCount := 0
248+
done := make(chan struct{})
249+
250+
go func() {
251+
for evt := range eventChan {
252+
if evt.Type == EventTypeConfigSaved {
253+
receivedCount++
254+
if receivedCount == 10 {
255+
close(done)
256+
return
257+
}
258+
}
259+
}
260+
}()
261+
262+
// Emit 10 events concurrently
263+
var wg sync.WaitGroup
264+
for i := 0; i < 10; i++ {
265+
wg.Add(1)
266+
go func(idx int) {
267+
defer wg.Done()
268+
time.Sleep(time.Millisecond * time.Duration(idx*10))
269+
rt.emitConfigSaved("/test/path")
270+
}(i)
271+
}
272+
273+
wg.Wait()
274+
275+
// Wait for all events to be received
276+
select {
277+
case <-done:
278+
assert.Equal(t, 10, receivedCount, "Should receive all 10 events")
279+
case <-time.After(3 * time.Second):
280+
t.Fatalf("Only received %d of 10 events", receivedCount)
281+
}
282+
}
283+

internal/runtime/events.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const (
1010
EventTypeServersChanged EventType = "servers.changed"
1111
// EventTypeConfigReloaded is emitted after configuration reload completes.
1212
EventTypeConfigReloaded EventType = "config.reloaded"
13+
// EventTypeConfigSaved is emitted after configuration is successfully saved to disk.
14+
EventTypeConfigSaved EventType = "config.saved"
1315
)
1416

1517
// Event is a typed notification published by the runtime event bus.

0 commit comments

Comments
 (0)