Skip to content

[BUG] KeepAliveScheduler does not evict sessions after ping failure, causing unbounded CPU growth #1022

Closed

Description

@lxq19991111

Description

When keep-alive-interval is configured on a Streamable HTTP MCP Server, KeepAliveScheduler periodically sends ping messages to all registered McpStreamableServerSession instances. When a session's stream is unavailable (client already disconnected), the ping fails and a WARN is logged, but the session is never removed from the session map. Over time, dead sessions accumulate indefinitely, causing increasing CPU usage as the scheduler iterates over a growing list of unreachable sessions every interval.

Environment

  • Spring AI: 1.1.4
  • MCP Java SDK: (bundled with Spring AI 1.1.4)
  • Java: JDK 25
  • Server: Tomcat (embedded via Spring Boot 3.4.1)
  • Transport: Streamable HTTP (spring.ai.mcp.server.protocol=STREAMABLE)

Configuration

spring:
  ai:
    mcp:
      server:
        type: SYNC
        protocol: STREAMABLE
        streamable-http:
          mcp-endpoint: /mcp
          keep-alive-interval: 30s

Steps to Reproduce

  1. Deploy an MCP Server with Streamable HTTP transport and keep-alive-interval: 30s
  2. Have multiple MCP clients connect, call tools, then disconnect (close TCP)
  3. Wait a few minutes and observe logs and CPU usage

Observed Behavior

Logs fill with repeated warnings every 30 seconds, one per dead session:

WARN [io.modelcontextprotocol.util.KeepAliveScheduler]
Failed to send keep-alive ping to session McpStreamableServerSession@3656e950: Stream unavailable for session d0a2a5b9-d427-49f6-8f0b-6c37293e2e35

WARN [io.modelcontextprotocol.util.KeepAliveScheduler]
Failed to send keep-alive ping to session McpStreamableServerSession@917a226: Stream unavailable for session 5e87f163-f127-4793-9fec-cd35b6a59327

WARN [io.modelcontextprotocol.util.KeepAliveScheduler]
Failed to send keep-alive ping to session McpStreamableServerSession@5795b3a2: Stream unavailable for session ...
  • Dead sessions are never removed from the internal map
  • CPU usage grows linearly with the number of accumulated dead sessions
  • The boundedElastic thread pool is consumed by failed ping attempts
  • Eventually causes CPU alerts and degrades ability to serve new requests

In our production environment, CPU went from normal to sustained 135%+ within hours of deployment, triggered by ~50 disconnected clients accumulating dead sessions.

Expected Behavior

When a ping fails (stream unavailable), KeepAliveScheduler should:

  1. Increment a failure counter for that session
  2. After N consecutive failures (e.g., 2-3), call session.close() and remove it from the session map
  3. Log the eviction at INFO level

Impact

  • Severity: High — causes progressive CPU degradation in production
  • Combined with the CLOSE-WAIT socket leak issue, can render a server completely unusable
  • No recovery without restart — dead sessions accumulate forever

Workaround

Disable keep-alive entirely:

spring:
  ai:
    mcp:
      server:
        streamable-http:
          keep-alive-interval: 0s

This stops the CPU waste but also removes the ability to detect stale sessions proactively.

Suggested Fix

In KeepAliveScheduler, after a failed ping:

// Pseudocode
void sendKeepAlive() {
    Iterator<Map.Entry<String, McpStreamableServerSession>> it = sessions.entrySet().iterator();
    while (it.hasNext()) {
        Map.Entry<String, McpStreamableServerSession> entry = it.next();
        try {
            entry.getValue().sendPing();
            failureCounters.remove(entry.getKey());
        } catch (Exception e) {
            int failures = failureCounters.merge(entry.getKey(), 1, Integer::sum);
            if (failures >= MAX_FAILURES) {
                entry.getValue().close();
                it.remove();
                failureCounters.remove(entry.getKey());
                log.info("Evicted dead session: {}", entry.getKey());
            }
        }
    }
}

Activity

  1. nikita-kibitkin commented on Jun 12, 2026

    @nikita-kibitkin

    I can take this.

    Looking at the code, KeepAliveScheduler only receives a Supplier<Flux> from the transport provider, so it cannot evict from the session map directly. I would add an on-failure hook to the scheduler and wire it in HttpServletStreamableServerTransportProvider, plus any other provider that creates a KeepAliveScheduler, so a failed keep-alive can close and remove the session through the existing transport cleanup path. Successful pings would stay unchanged, and this would not introduce a general session TTL policy.

    I would not evict on the first failure: #920 shows transient write failures can happen, and a streamable client without an open GET stream can still be alive and POSTing. A small consecutive-failure threshold, like the 2-3 failures suggested in the issue, seems like the right boundary.

    If that scope works, I will put up a PR.

  2. lxq19991111 commented on Jun 12, 2026

    @lxq19991111
    Author

    Thanks for digging into this. @nikita-kibitkin

    I’ve already opened the related upstream MCP Java SDK issue here: #1021

    Since the relevant pieces (KeepAliveScheduler, streamable session handling, and transport cleanup) live in the MCP Java SDK, I’ve started working on the upstream fix there first and will link the PR back here once it is ready.

    My expectation is that this Spring AI issue will likely be resolved primarily through the MCP-side change, with Spring AI then validating the upgraded behavior and only adding follow-up integration changes if needed.

    If you’d still like to collaborate, that would be very helpful — especially on validating the behavior from the Spring AI side, regression coverage, and checking whether any Spring-specific follow-up is still needed after the MCP fix lands.

  3. lxq19991111 commented on Jun 13, 2026

    @lxq19991111
    Author

    Small correction from my side: I realized I mixed up the issue context in my previous reply. I thought I was replying to the related Spring AI issue here: spring-projects/spring-ai#6384, but this issue is already in the MCP Java SDK repo.

    To clarify:

    I’ve already spent time digging into the streamable session lifecycle while preparing #1027, and I’d be interested in helping with this follow-up as well.

    Your proposed direction makes sense to me: add a keep-alive failure hook / consecutive failure threshold, and let the transport provider perform session cleanup because it owns the session registry. I also agree eviction should not happen on the first failure.

    If you have not started on the implementation yet, I’d be happy to prepare a small draft PR based on the direction you outlined, with focused tests. I’d also very much welcome your review and input on the approach. @nikita-kibitkin

  4. lxq19991111 commented on Jun 13, 2026

    @lxq19991111
    Author

    Thanks for confirming this, and also thanks again for the guidance on #1021.

    For this issue, your production observations line up with the failure mode described here: keep-alive currently detects dead sessions by logging failed pings, but it does not help the transport recover, so the scheduler keeps doing repeated failed work every interval.

    I agree with the direction of evicting sessions after failed pings, but I have two concerns about the exact boundary:

    1. KeepAliveScheduler currently receives sessions from a supplier and does not own the session registry, so I think it should report failures rather than directly remove from the map.
    2. TTL is useful as a defense-in-depth policy, but it may be better as a follow-up or optional policy instead of part of the first minimal fix.

    Based on the discussion so far, I’m thinking of preparing a conservative MCP SDK draft PR with this shape:

    • Add a keep-alive failure callback/hook from KeepAliveScheduler.
    • Track consecutive ping failures per session.
    • Reset the failure count after a successful ping.
    • Evict only after a small threshold, not on the first failure.
    • Let the transport provider close/remove the session through its existing cleanup path, since it owns the session registry.
    • Leave session TTL out of the first PR unless maintainers prefer including it.

    This should address the resource leak while keeping the first change focused and avoiding a general session lifetime policy.

    I’ll put together a small draft PR along these lines and would very much welcome your review and input, especially on the failure threshold and whether TTL should be included now or deferred. @nikita-kibitkin @Ben-Home

  5. lxq19991111 commented on Jun 14, 2026

    @lxq19991111
    Author

    I opened #1028 for the Streamable HTTP keep-alive dead-session eviction part of this issue.

    The PR adds keep-alive success/failure hooks to KeepAliveScheduler, tracks consecutive failures in HttpServletStreamableServerTransportProvider, and evicts/closes the session after 3 consecutive failed keep-alive pings.

    I intentionally kept the eviction logic in the Streamable HTTP servlet provider rather than in KeepAliveScheduler, because the provider owns the session registry and can safely remove only the currently registered session instance.

    I also left the deprecated SSE servlet provider unchanged.

    Testing:

    • mvn -pl mcp-core -Dtest=KeepAliveSchedulerTests,HttpServletStreamableServerTransportProviderTests test
    • mvn -pl mcp-core test
  6. added
    bugSomething isn't working
    ready for workThe goal is clear and work towards it can be commenced
    P1Significant bug affecting many users, highly requested feature
    on Aug 17, 2026
  7. added theissue type on Aug 17, 2026
  8. added this to the 2.1.0 Planning milestone on Aug 31, 2026
  9. Kehrlann commented on Sep 1, 2026

    @Kehrlann
    Contributor

    This has been reported originally in #471 .

    Let's keep the discussion here, as it has more context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featurebugSomething isn't workingready for workThe goal is clear and work towards it can be commenced

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions