Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions internal/launcher/health_monitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package launcher

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

Expand Down Expand Up @@ -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},
}
Comment thread
lpcox marked this conversation as resolved.
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)
}
Loading