Remove inactive sessions with a "sweeper" mechanism
- Sessions are marked active when a client makes a request - A session with an open stream is also considered active - Inactive sessions are removed at a regular interval Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
04bc1e8710
commit
50991f9b6c
3 changed files with 431 additions and 89 deletions
|
|
@ -9,7 +9,9 @@ import java.io.PrintWriter;
|
|||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
|
|
@ -28,6 +30,8 @@ import io.modelcontextprotocol.spec.ProtocolVersions;
|
|||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
import jakarta.servlet.AsyncContext;
|
||||
import jakarta.servlet.AsyncEvent;
|
||||
import jakarta.servlet.AsyncListener;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.annotation.WebServlet;
|
||||
import jakarta.servlet.http.HttpServlet;
|
||||
|
|
@ -35,8 +39,10 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
/**
|
||||
* Server-side implementation of the Model Context Protocol (MCP) streamable transport
|
||||
|
|
@ -115,6 +121,13 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
*/
|
||||
private final ConcurrentHashMap<String, McpStreamableServerSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* IDs of the sessions which received a request since the last sweep. The set is
|
||||
* swapped for an empty one on every sweep, so it only ever holds the activity of the
|
||||
* current interval.
|
||||
*/
|
||||
private final AtomicReference<Set<String>> activeSessions = new AtomicReference<>(ConcurrentHashMap.newKeySet());
|
||||
|
||||
private McpTransportContextExtractor<HttpServletRequest> contextExtractor;
|
||||
|
||||
/**
|
||||
|
|
@ -128,6 +141,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
*/
|
||||
private KeepAliveScheduler keepAliveScheduler;
|
||||
|
||||
/**
|
||||
* Periodic eviction of the sessions no client came back to. Activated if
|
||||
* sessionSweepInterval is set.
|
||||
*/
|
||||
private Disposable sessionSweeper;
|
||||
|
||||
/**
|
||||
* Security validator for validating HTTP requests.
|
||||
*/
|
||||
|
|
@ -146,11 +165,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
* @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
|
||||
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
|
||||
* positive.
|
||||
* @param sessionSweepInterval The interval at which idle sessions are evicted. If
|
||||
* null, no sweeping will be scheduled.
|
||||
* @throws IllegalArgumentException if any parameter is null
|
||||
*/
|
||||
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
|
||||
boolean disallowDelete, McpTransportContextExtractor<HttpServletRequest> contextExtractor,
|
||||
Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
|
||||
Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize,
|
||||
Duration sessionSweepInterval) {
|
||||
Assert.notNull(jsonMapper, "JsonMapper must not be null");
|
||||
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
|
||||
Assert.notNull(contextExtractor, "Context extractor must not be null");
|
||||
|
|
@ -169,11 +191,72 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
this.keepAliveScheduler = KeepAliveScheduler.builder(this::sessionsToPing)
|
||||
.initialDelay(keepAliveInterval)
|
||||
.interval(keepAliveInterval)
|
||||
.onPingFailure(session -> {
|
||||
// The stream the ping was written to is dead. The session survives:
|
||||
// the client may reconnect to it, and the idle timeout reclaims it if
|
||||
// it never does.
|
||||
if (session instanceof McpStreamableServerSession streamableSession) {
|
||||
streamableSession.releaseListeningStream();
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
this.keepAliveScheduler.start();
|
||||
}
|
||||
|
||||
if (sessionSweepInterval != null) {
|
||||
this.sessionSweeper = Flux.interval(sessionSweepInterval, sessionSweepInterval, Schedulers.boundedElastic())
|
||||
.doOnNext(tick -> {
|
||||
// Each sweep runs in its own subscription, so that a sweep failing
|
||||
// does not terminate the interval and disable sweeping altogether
|
||||
Mono.fromRunnable(this::sweepSessions)
|
||||
.doOnError(e -> logger.error("Session sweep failed", e))
|
||||
.onErrorComplete()
|
||||
.subscribe();
|
||||
})
|
||||
.onErrorComplete(error -> {
|
||||
logger.error("Session sweeper error", error);
|
||||
return true;
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Evicts the sessions no client is using anymore. A session is kept if it holds an
|
||||
* open stream, which a client can legitimately sit on without ever writing to it, or
|
||||
* if it received a request during the interval which just elapsed. Anything else is a
|
||||
* session whose client went away without deleting it: the protocol lets a client
|
||||
* reconnect to a session, so nothing else ever reclaims it.
|
||||
*/
|
||||
private void sweepSessions() {
|
||||
if (this.isClosing) {
|
||||
return;
|
||||
}
|
||||
Set<String> active = this.activeSessions.getAndSet(ConcurrentHashMap.newKeySet());
|
||||
this.sessions.values().removeIf(session -> {
|
||||
if (session.hasOpenStream() || active.contains(session.getId())) {
|
||||
return false;
|
||||
}
|
||||
logger.debug("Evicting idle session {}", session.getId());
|
||||
try {
|
||||
session.closeGracefully().block();
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.warn("Failed to close idle session {}: {}", session.getId(), e.getMessage());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the given session is being used, so that the next sweep does not
|
||||
* mistake it for a session whose client is gone.
|
||||
* @param sessionId the session the current request belongs to
|
||||
*/
|
||||
private void markSessionActive(String sessionId) {
|
||||
this.activeSessions.get().add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -264,6 +347,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
if (this.keepAliveScheduler != null) {
|
||||
this.keepAliveScheduler.shutdown();
|
||||
}
|
||||
if (this.sessionSweeper != null) {
|
||||
this.sessionSweeper.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +409,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
this.markSessionActive(sessionId);
|
||||
|
||||
logger.debug("Handling GET request for session: {}", sessionId);
|
||||
|
||||
|
|
@ -354,30 +441,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
|
||||
.listeningStream(sessionTransport);
|
||||
|
||||
asyncContext.addListener(new jakarta.servlet.AsyncListener() {
|
||||
@Override
|
||||
public void onComplete(jakarta.servlet.AsyncEvent event) throws IOException {
|
||||
logger.debug("SSE connection completed for session: {}", sessionId);
|
||||
listeningStream.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTimeout(jakarta.servlet.AsyncEvent event) throws IOException {
|
||||
logger.debug("SSE connection timed out for session: {}", sessionId);
|
||||
listeningStream.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(jakarta.servlet.AsyncEvent event) throws IOException {
|
||||
logger.debug("SSE connection error for session: {}", sessionId);
|
||||
listeningStream.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartAsync(jakarta.servlet.AsyncEvent event) throws IOException {
|
||||
// No action needed
|
||||
}
|
||||
});
|
||||
registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport);
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage());
|
||||
|
|
@ -385,34 +449,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the messages the client missed while its SSE stream was broken.
|
||||
* @param session the session the client is resuming
|
||||
* @param lastEventId the ID of the last event received by the client
|
||||
* @param sessionTransport the transport of the resumed SSE stream
|
||||
* @param transportContext the context extracted from the request
|
||||
* @return {@code true} if the replay completed, {@code false} if it failed, in which
|
||||
* case the transport has been closed
|
||||
*/
|
||||
private boolean tryReplayMissedMessages(McpStreamableServerSession session, String lastEventId,
|
||||
McpStreamableServerTransport sessionTransport, McpTransportContext transportContext) {
|
||||
try {
|
||||
for (McpSchema.JSONRPCMessage message : session.replay(lastEventId)
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
|
||||
.toIterable()) {
|
||||
sessionTransport.sendMessage(message)
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
|
||||
.block();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to replay messages for session {}: {}", session.getId(), e.getMessage());
|
||||
sessionTransport.close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles POST requests for incoming JSON-RPC messages from clients.
|
||||
* @param request The HTTP servlet request containing the JSON-RPC message
|
||||
|
|
@ -479,6 +515,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
});
|
||||
McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory
|
||||
.startSession(initializeRequest);
|
||||
this.markSessionActive(init.session().getId());
|
||||
this.sessions.put(init.session().getId(), init.session());
|
||||
|
||||
try {
|
||||
|
|
@ -530,6 +567,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
return;
|
||||
}
|
||||
|
||||
this.markSessionActive(sessionId);
|
||||
|
||||
if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) {
|
||||
session.accept(jsonrpcResponse)
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
|
||||
|
|
@ -554,6 +593,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
|
||||
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
|
||||
sessionId, asyncContext, response.getWriter());
|
||||
registerAsyncLifecycle(asyncContext, sessionId, sessionTransport::close);
|
||||
|
||||
try {
|
||||
session.responseStream(jsonrpcRequest, sessionTransport)
|
||||
|
|
@ -710,6 +750,70 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
super.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the messages the client missed while its SSE stream was broken.
|
||||
* @param session the session the client is resuming
|
||||
* @param lastEventId the ID of the last event received by the client
|
||||
* @param sessionTransport the transport of the resumed SSE stream
|
||||
* @param transportContext the context extracted from the request
|
||||
* @return {@code true} if the replay completed, {@code false} if it failed, in which
|
||||
* case the transport has been closed
|
||||
*/
|
||||
private static boolean tryReplayMissedMessages(McpStreamableServerSession session, String lastEventId,
|
||||
McpStreamableServerTransport sessionTransport, McpTransportContext transportContext) {
|
||||
try {
|
||||
for (McpSchema.JSONRPCMessage message : session.replay(lastEventId)
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
|
||||
.toIterable()) {
|
||||
sessionTransport.sendMessage(message)
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
|
||||
.block();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to replay messages for session {}: {}", session.getId(), e.getMessage());
|
||||
sessionTransport.close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener releasing the SSE stream carried by the given asynchronous
|
||||
* request once the container is done with it, whether the client went away, the
|
||||
* request timed out or it errored. Without this, the connection is left open, holding
|
||||
* on to a socket and a container thread, until the process restarts.
|
||||
* @param asyncContext the asynchronous context of the SSE request
|
||||
* @param sessionId the session the stream belongs to
|
||||
* @param onConnectionEnd the action releasing the stream
|
||||
*/
|
||||
private static void registerAsyncLifecycle(AsyncContext asyncContext, String sessionId, Runnable onConnectionEnd) {
|
||||
asyncContext.addListener(new AsyncListener() {
|
||||
@Override
|
||||
public void onComplete(AsyncEvent event) throws IOException {
|
||||
logger.debug("SSE connection completed for session: {}", sessionId);
|
||||
onConnectionEnd.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTimeout(AsyncEvent event) throws IOException {
|
||||
logger.debug("SSE connection timed out for session: {}", sessionId);
|
||||
onConnectionEnd.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(AsyncEvent event) throws IOException {
|
||||
logger.debug("SSE connection error for session: {}", sessionId);
|
||||
onConnectionEnd.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartAsync(AsyncEvent event) throws IOException {
|
||||
// No action needed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of McpStreamableServerTransport for HttpServlet SSE sessions. This
|
||||
* class handles the transport-level communication for a specific client session.
|
||||
|
|
@ -719,7 +823,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
* underlying PrintWriter to prevent race conditions when multiple threads attempt to
|
||||
* send messages concurrently.
|
||||
*/
|
||||
|
||||
private class HttpServletStreamableMcpSessionTransport implements McpStreamableServerTransport {
|
||||
|
||||
private final String sessionId;
|
||||
|
|
@ -783,9 +886,10 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId);
|
||||
}
|
||||
catch (Exception e) {
|
||||
// The connection is gone, the session is not: the client may come
|
||||
// back for it, and the idle timeout reclaims it if it never does
|
||||
logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage());
|
||||
HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
|
||||
this.asyncContext.complete();
|
||||
this.close();
|
||||
}
|
||||
finally {
|
||||
lock.unlock();
|
||||
|
|
@ -829,8 +933,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
// HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
|
||||
this.asyncContext.complete();
|
||||
logger.debug("Successfully completed async context for session {}", sessionId);
|
||||
}
|
||||
|
|
@ -875,6 +977,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
|
||||
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
|
||||
|
||||
private Duration sessionSweepInterval = Duration.ofMinutes(30);
|
||||
|
||||
/**
|
||||
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
|
||||
* messages.
|
||||
|
|
@ -974,6 +1078,22 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the interval at which idle sessions are evicted. A session is idle once it
|
||||
* holds no open stream and has received no request for a full interval, which is
|
||||
* how a session whose client went away without deleting it looks. Nothing else
|
||||
* reclaims those sessions, as the protocol lets a client reconnect to a session
|
||||
* it has been disconnected from.
|
||||
* @param sessionSweepInterval The interval between two sweeps. If null, no
|
||||
* sweeping will be scheduled and sessions are kept until they are deleted or the
|
||||
* server shuts down. Defaults to 30 minutes.
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder sessionSweepInterval(Duration sessionSweepInterval) {
|
||||
this.sessionSweepInterval = sessionSweepInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new instance of {@link HttpServletStreamableServerTransportProvider}
|
||||
* with the configured settings.
|
||||
|
|
@ -984,7 +1104,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
|
|||
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
|
||||
return new HttpServletStreamableServerTransportProvider(
|
||||
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete,
|
||||
contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize);
|
||||
contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize, sessionSweepInterval);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public class McpStreamableServerSession implements McpLoggableSession {
|
|||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpStreamableServerSession.class);
|
||||
|
||||
private final ConcurrentHashMap<Object, McpStreamableServerSessionStream> requestIdToStream = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, McpStreamableServerSessionStream> requestIdToStream = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Every stream with a connection currently attached, whether the listening stream or
|
||||
|
|
@ -195,6 +195,16 @@ public class McpStreamableServerSession implements McpLoggableSession {
|
|||
return this.listeningStreamRef.get() instanceof McpStreamableServerSessionStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session currently has at least one stream with a connection attached,
|
||||
* whether the listening stream or a POST response stream. A client holding such a
|
||||
* connection open is still there, however long it stays silent on it.
|
||||
* @return {@code true} if the session has an open stream
|
||||
*/
|
||||
public boolean hasOpenStream() {
|
||||
return !this.openStreams.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a listening stream (the generic HTTP GET request, with or without a
|
||||
* Last-Event-ID header). A session addresses a single listening stream at a time, so
|
||||
|
|
@ -398,7 +408,7 @@ public class McpStreamableServerSession implements McpLoggableSession {
|
|||
*/
|
||||
public final class McpStreamableServerSessionStream implements McpLoggableSession {
|
||||
|
||||
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
|
||||
|
||||
private final McpStreamableServerTransport connection;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package io.modelcontextprotocol.util;
|
|||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -57,6 +58,9 @@ public class KeepAliveScheduler {
|
|||
/** Supplier for reactive McpSession instances */
|
||||
private final Supplier<Flux<McpSession>> mcpSessions;
|
||||
|
||||
/** Invoked with the session whose keep-alive ping went unanswered */
|
||||
private final Consumer<McpSession> onPingFailure;
|
||||
|
||||
/**
|
||||
* Creates a KeepAliveScheduler with a custom scheduler, initial delay, interval and a
|
||||
* supplier for McpSession instances.
|
||||
|
|
@ -64,13 +68,15 @@ public class KeepAliveScheduler {
|
|||
* @param initialDelay Initial delay before the first keepAlive call
|
||||
* @param interval Interval between subsequent keepAlive calls
|
||||
* @param mcpSessions Supplier for McpSession instances
|
||||
* @param onPingFailure Callback invoked with the session whose ping went unanswered
|
||||
*/
|
||||
KeepAliveScheduler(Scheduler scheduler, Duration initialDelay, Duration interval,
|
||||
Supplier<Flux<McpSession>> mcpSessions) {
|
||||
Supplier<Flux<McpSession>> mcpSessions, Consumer<McpSession> onPingFailure) {
|
||||
this.scheduler = scheduler;
|
||||
this.initialDelay = initialDelay;
|
||||
this.interval = interval;
|
||||
this.mcpSessions = mcpSessions;
|
||||
this.onPingFailure = onPingFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,8 +98,14 @@ public class KeepAliveScheduler {
|
|||
.doOnNext(tick -> {
|
||||
this.mcpSessions.get()
|
||||
.flatMap(session -> session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF)
|
||||
.doOnError(e -> logger.warn("Failed to send keep-alive ping to session {}: {}", session,
|
||||
e.getMessage()))
|
||||
// A ping has to be answered before the next one is due. The
|
||||
// request timeout of the session is unrelated to keeping the
|
||||
// connection alive, and is measured in hours by default.
|
||||
.timeout(this.interval)
|
||||
.doOnError(e -> {
|
||||
logger.warn("Keep-alive ping to session {} failed: {}", session, e.getMessage());
|
||||
this.onPingFailure.accept(session);
|
||||
})
|
||||
.onErrorComplete())
|
||||
.subscribe();
|
||||
})
|
||||
|
|
@ -154,6 +166,9 @@ public class KeepAliveScheduler {
|
|||
|
||||
private Supplier<Flux<McpSession>> mcpSessions;
|
||||
|
||||
private Consumer<McpSession> onPingFailure = session -> {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new Builder instance with a supplier for McpSession instances.
|
||||
* @param mcpSessions The supplier for McpSession instances
|
||||
|
|
@ -204,12 +219,28 @@ public class KeepAliveScheduler {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback invoked when a session does not answer a keep-alive ping
|
||||
* within the keep-alive interval. An unanswered ping means the connection the
|
||||
* ping was written to is dead, which the operating system does not necessarily
|
||||
* report: writing to a connection whose peer is gone keeps succeeding until it
|
||||
* resets. It does not mean the session itself is over, as the client is free to
|
||||
* reconnect to it.
|
||||
* @param onPingFailure The callback receiving the unresponsive session
|
||||
* @return This builder instance for method chaining
|
||||
*/
|
||||
public Builder onPingFailure(Consumer<McpSession> onPingFailure) {
|
||||
Assert.notNull(onPingFailure, "onPingFailure must not be null");
|
||||
this.onPingFailure = onPingFailure;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns a new KeepAliveScheduler instance.
|
||||
* @return A new KeepAliveScheduler configured with the builder's settings
|
||||
*/
|
||||
public KeepAliveScheduler build() {
|
||||
return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions);
|
||||
return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions, onPingFailure);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue