diff --git a/internal/launcher/health_monitor_test.go b/internal/launcher/health_monitor_test.go index 5e16610a..34e8fbfb 100644 --- a/internal/launcher/health_monitor_test.go +++ b/internal/launcher/health_monitor_test.go @@ -2,6 +2,9 @@ package launcher import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "time" @@ -135,3 +138,81 @@ func TestHealthMonitor_RespectsContextCancellation(t *testing.T) { t.Fatal("health monitor did not stop after context cancellation") } } + +// TestHealthMonitor_MaxFailuresThresholdReached verifies that when a restart failure +// increments the counter to exactly maxConsecutiveRestartFailures, the counter +// is capped at the threshold and the server remains in an error state. +func TestHealthMonitor_MaxFailuresThresholdReached(t *testing.T) { + servers := map[string]*config.ServerConfig{ + "bad-server": {Type: "stdio", Command: "nonexistent-binary-xyz"}, + } + l := newTestLauncher(servers) + l.recordError("bad-server", "persistent failure") + + hm := NewHealthMonitor(l, time.Hour) + // Set counter to one below the threshold so the next failure reaches it. + hm.consecutiveFailures["bad-server"] = maxConsecutiveRestartFailures - 1 + + hm.checkAll() + + // Counter must now equal the threshold (not exceed it). + require.Equal(t, maxConsecutiveRestartFailures, hm.consecutiveFailures["bad-server"]) + // The server remains in error state because the restart failed. + state := l.GetServerState("bad-server") + assert.Equal(t, "error", state.Status) +} + +// mockMCPHandler returns an HTTP handler that responds to any POST with a +// minimal valid JSON-RPC initialize response. This is sufficient for the +// plain JSON-RPC transport fallback inside NewHTTPConnection to succeed. +func mockMCPHandler(t *testing.T) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "result": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "serverInfo": map[string]interface{}{ + "name": "mock-server", + "version": "1.0.0", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + data, err := json.Marshal(resp) + if err != nil { + http.Error(w, "marshal failed", http.StatusInternalServerError) + return + } + _, _ = w.Write(data) + }) +} + +// TestHealthMonitor_SuccessfulRestartResetCounter verifies that when a +// health-check restart succeeds, the consecutive-failure counter is reset to +// zero and the server transitions back to "running" state. +func TestHealthMonitor_SuccessfulRestartResetCounter(t *testing.T) { + mockServer := httptest.NewServer(mockMCPHandler(t)) + defer mockServer.Close() + + servers := map[string]*config.ServerConfig{ + "good-server": {Type: "http", URL: mockServer.URL, ConnectTimeout: 1}, + } + l := newTestLauncher(servers) + + // Simulate a server that previously failed. + l.recordError("good-server", "transient failure") + + hm := NewHealthMonitor(l, time.Hour) + hm.consecutiveFailures["good-server"] = 1 + + hm.checkAll() + + // A successful restart must reset the failure counter. + assert.Equal(t, 0, hm.consecutiveFailures["good-server"]) + // The server should now report as running. + state := l.GetServerState("good-server") + assert.Equal(t, "running", state.Status) +}