Skip to content

Commit 59e8329

Browse files
committed
fix(074): honor per-server discovery cadence + hot-reload global intervals
Addresses the two Codex review findings (MCP-1186) on PR #609. Finding 1 — per-server tool_discovery_interval was inert at runtime. The periodic background sweep listed every connected server every global cycle regardless of per-server overrides. The sweep now resolves each server's tool_discovery_interval and skips servers that are disabled (<=0) or not yet due (Manager.DiscoverToolsDue + per-server lastSweptAt tracking), and the indexing loop ticks at the smallest enabled per-server cadence so a short override is re-listed on time (Manager.ResolveToolDiscoverySweepTick). Event-driven callers keep using the full DiscoverTools sweep. Finding 2 — a global health_check_interval edit did not reach running clients. ApplyConfig now propagates the new global config to the upstream manager and every managed client (Manager/Client.SetGlobalConfig), whose health loop re-resolves the interval each cycle; health_check_interval and tool_discovery_interval are added to hot-reload change detection. globalConfig on the manager and managed client is now an atomic pointer (mirrors the cfg pattern) so the swap is lock-free and race-free. The sweep tick is computed from the manager's thread-safe per-client config snapshots, never by iterating the shared (copy-on-write) config snapshot's Servers slice, which raced in-place config mutation in the background loop. Tests: per-server cadence gate, tick resolution, swept-state tracking, manager + client SetGlobalConfig propagation, health-interval hot-reload, and the two new change-detection fields. Full internal suite green under -race (0 races); both editions build. Related #608
1 parent 6d0f41a commit 59e8329

11 files changed

Lines changed: 585 additions & 51 deletions

internal/runtime/config_hotreload.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult {
9999
result.ChangedFields = append(result.ChangedFields, "call_tool_timeout")
100100
}
101101

102+
// Discovery & health-check cadence (spec 074 — hot-reloadable). The health
103+
// loop (managed client) and indexing loop (runtime) re-resolve their interval
104+
// each cycle, and ApplyConfig propagates the new global config to the upstream
105+
// manager + managed clients, so a global edit takes effect without a restart
106+
// (FR-012/SC-002). Tracking these keeps a lone interval edit from being
107+
// reported as "no changes detected".
108+
if !reflect.DeepEqual(oldCfg.HealthCheckInterval, newCfg.HealthCheckInterval) {
109+
result.ChangedFields = append(result.ChangedFields, "health_check_interval")
110+
}
111+
if !reflect.DeepEqual(oldCfg.ToolDiscoveryInterval, newCfg.ToolDiscoveryInterval) {
112+
result.ChangedFields = append(result.ChangedFields, "tool_discovery_interval")
113+
}
114+
102115
// Logging configuration (can be hot-reloaded)
103116
if !reflect.DeepEqual(oldCfg.Logging, newCfg.Logging) {
104117
result.ChangedFields = append(result.ChangedFields, "logging")

internal/runtime/config_hotreload_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,54 @@ func TestDetectConfigChanges_Observability(t *testing.T) {
3535
assert.False(t, result.RequiresRestart, "cadence change is hot-reloadable")
3636
}
3737

38+
// TestDetectConfigChanges_DiscoveryHealthIntervals (MCP-1189 / Codex finding #2):
39+
// a global health_check_interval / tool_discovery_interval edit must be detected
40+
// as a hot-reloadable change so ApplyConfig propagates the new cadence to the
41+
// running upstream manager + managed clients. Without this, a lone interval edit
42+
// would be reported as "no changes detected" (FR-012/SC-002).
43+
func TestDetectConfigChanges_DiscoveryHealthIntervals(t *testing.T) {
44+
mk := func(health, discovery *config.Duration) *config.Config {
45+
return &config.Config{
46+
Listen: "127.0.0.1:8080", DataDir: "/d", TLS: &config.TLSConfig{},
47+
HealthCheckInterval: health,
48+
ToolDiscoveryInterval: discovery,
49+
}
50+
}
51+
52+
t.Run("health_check_interval change detected", func(t *testing.T) {
53+
old45 := config.Duration(45 * time.Second)
54+
new10 := config.Duration(10 * time.Second)
55+
result := DetectConfigChanges(mk(&old45, nil), mk(&new10, nil))
56+
require.True(t, result.Success)
57+
assert.Contains(t, result.ChangedFields, "health_check_interval")
58+
assert.False(t, result.RequiresRestart, "interval change is hot-reloadable")
59+
})
60+
61+
t.Run("tool_discovery_interval change detected", func(t *testing.T) {
62+
old5m := config.Duration(5 * time.Minute)
63+
new1m := config.Duration(1 * time.Minute)
64+
result := DetectConfigChanges(mk(nil, &old5m), mk(nil, &new1m))
65+
require.True(t, result.Success)
66+
assert.Contains(t, result.ChangedFields, "tool_discovery_interval")
67+
assert.False(t, result.RequiresRestart)
68+
})
69+
70+
t.Run("setting from unset (nil -> value) detected", func(t *testing.T) {
71+
val := config.Duration(0) // disabling the loop
72+
result := DetectConfigChanges(mk(nil, nil), mk(&val, nil))
73+
require.True(t, result.Success)
74+
assert.Contains(t, result.ChangedFields, "health_check_interval")
75+
})
76+
77+
t.Run("unchanged intervals not reported", func(t *testing.T) {
78+
same := config.Duration(45 * time.Second)
79+
other := config.Duration(45 * time.Second)
80+
result := DetectConfigChanges(mk(&same, nil), mk(&other, nil))
81+
require.True(t, result.Success)
82+
assert.NotContains(t, result.ChangedFields, "health_check_interval")
83+
})
84+
}
85+
3886
func TestDetectConfigChanges(t *testing.T) {
3987
baseConfig := &config.Config{
4088
Listen: "127.0.0.1:8080",

internal/runtime/lifecycle.go

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,16 @@ func (r *Runtime) connectAllWithRetry(ctx context.Context) {
229229
const toolDiscoveryDisabledRecheckInterval = 5 * time.Minute
230230

231231
// planToolDiscoveryCycle decides one iteration of the indexing loop: whether to
232-
// run a periodic sweep and how long to wait first. A positive interval sweeps
233-
// on that cadence; a non-positive interval disables the sweep and waits the
234-
// re-check window.
235-
func planToolDiscoveryCycle(interval, disabledRecheck time.Duration) (sweep bool, wait time.Duration) {
236-
if interval <= 0 {
232+
// run a periodic sweep and how long to wait first. When at least one server (or
233+
// the global default) has a positive resolved interval the loop ticks at the
234+
// smallest such cadence (tick) and sweeps; when every interval is disabled
235+
// (anyEnabled=false) the loop waits the re-check window without sweeping so a
236+
// later hot-reload can re-enable it.
237+
func planToolDiscoveryCycle(tick time.Duration, anyEnabled bool, disabledRecheck time.Duration) (sweep bool, wait time.Duration) {
238+
if !anyEnabled || tick <= 0 {
237239
return false, disabledRecheck
238240
}
239-
return true, interval
241+
return true, tick
240242
}
241243

242244
func (r *Runtime) backgroundToolIndexing(ctx context.Context) {
@@ -250,17 +252,22 @@ func (r *Runtime) backgroundToolIndexing(ctx context.Context) {
250252
return
251253
}
252254

253-
// Re-resolve the global tool-discovery interval every cycle so a config
254-
// hot-reload changes the sweep cadence (or disables it) without a restart
255-
// (spec 074, FR-012). A resolved value <= 0 disables the periodic sweep.
255+
// Re-resolve the tool-discovery cadence every cycle so a config hot-reload
256+
// changes the sweep interval (or disables it) without a restart (spec 074,
257+
// FR-012). The loop ticks at the smallest per-server cadence and the sweep
258+
// itself (DiscoverToolsDue) only re-lists servers whose own interval has
259+
// elapsed, so per-server overrides take effect (US3/SC-006/FR-005). The tick
260+
// is resolved from the upstream manager's thread-safe per-client config
261+
// snapshots — iterating r.Config().Servers here would race in-place config
262+
// mutation (the shared snapshot is copy-on-write; see runtime.Config()).
256263
for {
257-
interval := r.Config().ResolveToolDiscoveryInterval(nil)
258-
sweep, wait := planToolDiscoveryCycle(interval, toolDiscoveryDisabledRecheckInterval)
264+
tick, anyEnabled := r.upstreamManager.ResolveToolDiscoverySweepTick(r.Config())
265+
sweep, wait := planToolDiscoveryCycle(tick, anyEnabled, toolDiscoveryDisabledRecheckInterval)
259266
timer := time.NewTimer(wait)
260267
select {
261268
case <-timer.C:
262269
if sweep {
263-
_ = r.DiscoverAndIndexTools(ctx)
270+
_ = r.discoverAndIndexTools(ctx, true)
264271
}
265272
case <-ctx.Done():
266273
timer.Stop()
@@ -302,15 +309,31 @@ func (r *Runtime) backgroundSessionCleanup(ctx context.Context) {
302309
}
303310
}
304311

305-
// DiscoverAndIndexTools discovers tools from upstream servers and indexes them.
312+
// DiscoverAndIndexTools discovers tools from ALL connected upstream servers and
313+
// indexes them. Used by event-driven callers (boot, server reload, manual
314+
// refresh) that want a full sweep regardless of per-server cadence.
306315
func (r *Runtime) DiscoverAndIndexTools(ctx context.Context) error {
316+
return r.discoverAndIndexTools(ctx, false)
317+
}
318+
319+
// discoverAndIndexTools discovers tools and updates the index. When dueOnly is
320+
// true (the periodic spec-074 sweep) only servers whose per-server
321+
// tool_discovery_interval has elapsed are re-listed; servers omitted from the
322+
// sweep keep their last-good index snapshot, so the index does not shrink.
323+
func (r *Runtime) discoverAndIndexTools(ctx context.Context, dueOnly bool) error {
307324
if r.upstreamManager == nil || r.indexManager == nil {
308325
return fmt.Errorf("runtime managers not initialized")
309326
}
310327

311-
r.logger.Info("Discovering and indexing tools...")
328+
r.logger.Info("Discovering and indexing tools...", zap.Bool("due_only", dueOnly))
312329

313-
tools, err := r.upstreamManager.DiscoverTools(ctx)
330+
var tools []*config.ToolMetadata
331+
var err error
332+
if dueOnly {
333+
tools, err = r.upstreamManager.DiscoverToolsDue(ctx)
334+
} else {
335+
tools, err = r.upstreamManager.DiscoverTools(ctx)
336+
}
314337
if err != nil {
315338
return fmt.Errorf("failed to discover tools: %w", err)
316339
}

internal/runtime/runtime.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,6 +1251,15 @@ func (r *Runtime) ApplyConfig(newCfg *config.Config, cfgPath string) (*ConfigApp
12511251
r.cfgPath = cfgPath
12521252
}
12531253

1254+
// Propagate the new global config to the upstream manager and every running
1255+
// managed client so their background health-check loops re-resolve the new
1256+
// global health_check_interval (and Docker/discovery decisions) without a
1257+
// restart (spec 074, FR-012/SC-002). The loops re-read the interval each
1258+
// cycle, so this atomic swap is all that's needed.
1259+
if r.upstreamManager != nil {
1260+
r.upstreamManager.SetGlobalConfig(newCfg)
1261+
}
1262+
12541263
// Apply configuration changes to components
12551264
r.logger.Info("Applying configuration hot-reload",
12561265
zap.Strings("changed_fields", result.ChangedFields))

internal/runtime/tool_discovery_interval_test.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,26 @@ import (
88
)
99

1010
// TestPlanToolDiscoveryCycle covers the pure decision the background indexing
11-
// loop makes each iteration: a positive interval sweeps on that cadence; a
12-
// non-positive interval (disabled) skips the periodic sweep and waits the
13-
// fallback re-check window so a hot-reload can re-enable it (spec 074, FR-006).
11+
// loop makes each iteration: when at least one server (or the global default)
12+
// has a positive resolved interval, the loop sweeps and ticks at that cadence;
13+
// when every interval is disabled it skips the periodic sweep and waits the
14+
// fallback re-check window so a hot-reload can re-enable it (spec 074,
15+
// FR-006/FR-012). The tick itself is resolved by the upstream manager from its
16+
// thread-safe per-client snapshots (see TestResolveToolDiscoverySweepTick in the
17+
// upstream package) — deliberately not from r.Config().Servers, which would race
18+
// in-place config mutation in the background loop (MCP-1189).
1419
func TestPlanToolDiscoveryCycle(t *testing.T) {
1520
const recheck = 5 * time.Minute
1621

17-
sweep, wait := planToolDiscoveryCycle(10*time.Minute, recheck)
18-
assert.True(t, sweep, "positive interval must sweep")
19-
assert.Equal(t, 10*time.Minute, wait)
22+
sweep, wait := planToolDiscoveryCycle(10*time.Minute, true, recheck)
23+
assert.True(t, sweep, "an enabled cadence must sweep")
24+
assert.Equal(t, 10*time.Minute, wait, "loop ticks at the resolved cadence")
2025

21-
sweep, wait = planToolDiscoveryCycle(0, recheck)
22-
assert.False(t, sweep, "0s must disable the periodic sweep")
26+
sweep, wait = planToolDiscoveryCycle(0, false, recheck)
27+
assert.False(t, sweep, "all-disabled must skip the periodic sweep")
2328
assert.Equal(t, recheck, wait, "disabled cycle waits the re-check window")
2429

25-
sweep, wait = planToolDiscoveryCycle(-1, recheck)
26-
assert.False(t, sweep, "negative interval must disable the periodic sweep")
30+
sweep, wait = planToolDiscoveryCycle(30*time.Second, false, recheck)
31+
assert.False(t, sweep, "anyEnabled=false dominates even with a positive tick")
2732
assert.Equal(t, recheck, wait)
2833
}

internal/upstream/managed/client.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,13 @@ type Client struct {
3535
StateManager *types.StateManager // Public field for callback access
3636

3737
// Configuration for creating fresh connections
38-
logConfig *config.LogConfig
39-
globalConfig *config.Config
38+
logConfig *config.LogConfig
39+
// globalConfig holds the proxy-wide config as an atomic pointer so a config
40+
// hot-reload can swap it under the running background loops (health-check
41+
// interval re-resolution, spec 074 FR-012) without a lock and without racing
42+
// the readers. Mirrors the cfg atomic-pointer rationale above. Access via
43+
// GetGlobalConfig() / SetGlobalConfig() only — never touch the field directly.
44+
globalConfig atomic.Pointer[config.Config]
4045
storage *storage.BoltDB
4146

4247
// Connection state protection
@@ -116,12 +121,12 @@ func NewClient(id string, serverConfig *config.ServerConfig, logger *zap.Logger,
116121
logger: logger.With(zap.String("component", "managed_client")),
117122
StateManager: types.NewStateManager(),
118123
logConfig: logConfig,
119-
globalConfig: globalConfig,
120124
storage: storage,
121125
stopMonitoring: make(chan struct{}),
122126
healthProbe: coreClient,
123127
}
124128
mc.cfg.Store(serverConfig)
129+
mc.globalConfig.Store(globalConfig)
125130

126131
// Set up state change callback
127132
mc.StateManager.SetStateChangeCallback(mc.onStateChange)
@@ -431,7 +436,8 @@ func (mc *Client) ShouldRetry() bool {
431436
// IsDockerIsolated returns true if this server will use Docker isolation.
432437
// Used to select appropriate connect timeouts (Docker containers need more time for package installation).
433438
func (mc *Client) IsDockerIsolated() bool {
434-
if mc.globalConfig == nil || mc.globalConfig.DockerIsolation == nil || !mc.globalConfig.DockerIsolation.Enabled {
439+
gc := mc.globalConfig.Load()
440+
if gc == nil || gc.DockerIsolation == nil || !gc.DockerIsolation.Enabled {
435441
return false
436442
}
437443
// Check if server has isolation explicitly disabled
@@ -781,11 +787,26 @@ func (mc *Client) stopBackgroundMonitoring() {
781787
// restart (spec 074, FR-012).
782788
const healthCheckDisabledRecheckInterval = 30 * time.Second
783789

790+
// GetGlobalConfig returns the current proxy-wide config snapshot (may be nil for
791+
// hand-constructed test clients). Lock-free; safe to call whether or not mc.mu
792+
// is held.
793+
func (mc *Client) GetGlobalConfig() *config.Config {
794+
return mc.globalConfig.Load()
795+
}
796+
797+
// SetGlobalConfig swaps the proxy-wide config the background loops re-resolve
798+
// against. Called on a config hot-reload (via Manager.SetGlobalConfig) so the
799+
// resettable health-check timer picks up a new global interval without a restart
800+
// (spec 074, FR-012). Lock-free atomic swap.
801+
func (mc *Client) SetGlobalConfig(cfg *config.Config) {
802+
mc.globalConfig.Store(cfg)
803+
}
804+
784805
// resolveHealthCheckInterval resolves this server's effective health-check
785806
// interval (per-server override → global → built-in default). A nil
786807
// globalConfig (hand-constructed clients) falls back to the built-in default.
787808
func (mc *Client) resolveHealthCheckInterval() time.Duration {
788-
cfg := mc.globalConfig
809+
cfg := mc.globalConfig.Load()
789810
if cfg == nil {
790811
cfg = &config.Config{}
791812
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package managed
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
9+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
10+
)
11+
12+
// TestSetGlobalConfig_HealthIntervalHotReload is the client-level proof of Codex
13+
// finding #2's fix: the background health loop re-resolves resolveHealthCheckInterval
14+
// every cycle, so swapping the global config via SetGlobalConfig changes the
15+
// effective cadence of a RUNNING client without a restart (spec 074, FR-012).
16+
func TestSetGlobalConfig_HealthIntervalHotReload(t *testing.T) {
17+
mc := newTestClientForHealth(t)
18+
19+
// Boot global: 45s. The server has no per-server override, so the resolved
20+
// interval is the global value.
21+
mc.SetGlobalConfig(&config.Config{HealthCheckInterval: durPtrHC(45 * time.Second)})
22+
assert.Equal(t, 45*time.Second, mc.resolveHealthCheckInterval(),
23+
"resolved interval must follow the boot global")
24+
25+
// Operator edits the global cadence to 10s and ApplyConfig propagates it.
26+
mc.SetGlobalConfig(&config.Config{HealthCheckInterval: durPtrHC(10 * time.Second)})
27+
assert.Equal(t, 10*time.Second, mc.resolveHealthCheckInterval(),
28+
"a global hot-reload must change a running client's resolved cadence")
29+
30+
// Disabling the global loop (0s) must propagate too.
31+
mc.SetGlobalConfig(&config.Config{HealthCheckInterval: durPtrHC(0)})
32+
assert.LessOrEqual(t, mc.resolveHealthCheckInterval(), time.Duration(0),
33+
"0s global must disable the running client's probe loop")
34+
}
35+
36+
func durPtrHC(d time.Duration) *config.Duration {
37+
cd := config.Duration(d)
38+
return &cd
39+
}

internal/upstream/managed/health_loop_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestBackgroundHealthCheck_DisabledSkipsProbe(t *testing.T) {
3636
mc.stopMonitoring = make(chan struct{})
3737
fake := &fakeProber{}
3838
mc.healthProbe = fake
39-
mc.globalConfig = &config.Config{}
39+
mc.SetGlobalConfig(&config.Config{})
4040

4141
cfg := mc.GetConfig()
4242
zero := config.Duration(0)
@@ -59,7 +59,7 @@ func TestBackgroundHealthCheck_EnabledProbesOnInterval(t *testing.T) {
5959
mc.stopMonitoring = make(chan struct{})
6060
fake := &fakeProber{}
6161
mc.healthProbe = fake
62-
mc.globalConfig = &config.Config{}
62+
mc.SetGlobalConfig(&config.Config{})
6363

6464
cfg := mc.GetConfig()
6565
d := config.Duration(40 * time.Millisecond) // sub-validation value is fine for the loop mechanics

0 commit comments

Comments
 (0)