mcp: add configurable keepalive failure threshold (#982)

mcp: add configurable keepalive failure threshold

Introduce `KeepAliveFailureThreshold` option in both `ClientOptions` and
`ServerOptions` to control how many consecutive keepalive ping failures
are tolerated before closing the session.

This aligns with the MCP spec's guidance that "multiple failed pings MAY
trigger a connection reset," allowing operators to tune resilience
against transient network hiccups without immediately tearing down
otherwise healthy sessions.

A threshold of 0 or 1 (the default) closes on the first failure,
preserving existing behavior. Higher values let isolated misses pass
while still closing the session once consecutive failures reach the
threshold. A successful ping resets the counter.

Tolerated failures are logged at WARN level; the final failure that
closes the session is logged at ERROR level.

This is rework of #979.
This commit is contained in:
Tomas Dabašinskas 2026-05-31 11:25:49 +03:00 • committed by GitHub
commit 8805aa85f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1920,6 +1920,82 @@ func TestKeepAliveFailure_Logged(t *testing.T) {
})
}
// scriptedKeepaliveSession is a keepaliveSession test double whose Ping
// returns errors from a script (one entry consumed per call; the last entry
// repeats once exhausted), and records how many times Close was called. Ping
// returns immediately so the keepalive loop's pace is driven purely by the
// ticker, making the test deterministic under synctest.
type scriptedKeepaliveSession struct {
pingErrs []error
pingCalls atomic.Int64
closeCalls atomic.Int64
}
func (s *scriptedKeepaliveSession) Ping(context.Context, *PingParams) error {
n := int(s.pingCalls.Add(1)) - 1
if n >= len(s.pingErrs) {
n = len(s.pingErrs) - 1
}
return s.pingErrs[n]
}
func (s *scriptedKeepaliveSession) Close() error {
s.closeCalls.Add(1)
return nil
}
// TestStartKeepalive_FailureThreshold verifies that the session is kept alive
// across consecutive ping failures below the threshold and only closed once the
// threshold is reached.
func TestStartKeepalive_FailureThreshold(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const interval = 100 * time.Millisecond
sess := &scriptedKeepaliveSession{pingErrs: []error{errors.New("boom")}}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
var cancel context.CancelFunc
startKeepalive(sess, interval, 3, &cancel, logger)
defer cancel()
// After two ticks → two failures, still below threshold 3: not closed.
time.Sleep(2*interval + interval/2)
synctest.Wait()
if got := sess.closeCalls.Load(); got != 0 {
t.Fatalf("session closed below threshold: closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load())
}
// Third tick → third failure reaches threshold: session closed.
time.Sleep(interval)
synctest.Wait()
if got := sess.closeCalls.Load(); got != 1 {
t.Fatalf("expected one Close at threshold, got closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load())
}
})
}
// TestStartKeepalive_SuccessResetsFailures verifies that a successful ping
// resets the consecutive-failure counter, so an isolated failure between
// successes never accumulates toward the threshold.
func TestStartKeepalive_SuccessResetsFailures(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const interval = 100 * time.Millisecond
// fail, success, fail, fail, then success (the tail repeats): the run
// never has 3 consecutive failures, so the session is never closed.
sess := &scriptedKeepaliveSession{pingErrs: []error{
errors.New("boom"), nil, errors.New("boom"), errors.New("boom"), nil,
}}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
var cancel context.CancelFunc
startKeepalive(sess, interval, 3, &cancel, logger)
defer cancel()
time.Sleep(6 * interval)
synctest.Wait()
if got := sess.closeCalls.Load(); got != 0 {
t.Fatalf("session closed despite a success resetting the counter: closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load())
}
})
}
func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) {
// Adding the same tool pointer twice should not panic and should not
// produce duplicates in the server's tool list.