Skip to content

Commit c7cc1ba

Browse files
committed
Fix race condition in subscription/listen
Under highly contentious load, there might be a race condition in subscription/listen. Every change broadcast before subscribing to the change feed is lost. Subscription happens on a thread, and writing to the wire happens in another thread. The `ack` might be fully written to the transport, and received by the client, before the changes are subscribed to. A test harness might fire a change right after the test client receives `ack`, but before the changes are subscribed to, and therefore the change is lost. So we need to ensure that: 1. Changes are subscribed to BEFORE the ack 2. But the ACK goes first in any case (and only once) This is mostly a test-harness fix rather than a real-world scenario. Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 891cb4a commit c7cc1ba

2 files changed

Lines changed: 65 additions & 4 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,16 +104,26 @@ public void capabilities(ServerCapabilities.Builder builder) {
104104
}
105105

106106
private Mono<Result> listen(McpRequestContext ctx, SubscriptionFilter honoured, McpAsyncNotifier notifier) {
107+
// To avoid race conditions in test harnesses, need to ensure that:
108+
// 1. Changes are subscribed to BEFORE the ack
109+
// 2. But the ACK goes before any notification
110+
//
111+
// Achieve this by:
112+
// 1. Merging changes + ack
113+
// 2. Forcing ack to be sent first in the changes
114+
// 3. Caching the "send ack" so that it only ever goes out once
107115
Map<String, Object> subscriptionMeta = Map.of(MetaKeys.SUBSCRIPTION_ID, ctx.requestId());
108-
Mono<Void> ack = notifier.notify(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED,
109-
new SubscriptionsAcknowledgedParams(honoured, subscriptionMeta));
116+
Mono<Void> ack = notifier
117+
.notify(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED,
118+
new SubscriptionsAcknowledgedParams(honoured, subscriptionMeta))
119+
.cache();
110120

111121
Flux<Void> forwardChanges = this.publisher.changes()
112122
.filter(change -> matches(change, honoured))
113123
.takeUntilOther(this.shutdown.asMono())
114-
.concatMap(change -> notifier.notify(methodFor(change), paramsFor(change, subscriptionMeta)));
124+
.concatMap(change -> ack.then(notifier.notify(methodFor(change), paramsFor(change, subscriptionMeta))));
115125

116-
return ack.thenMany(forwardChanges)
126+
return Flux.merge(forwardChanges, ack)
117127
.then(Mono.fromSupplier(() -> (Result) SubscriptionsListenResult.forSubscription(ctx.requestId())));
118128
}
119129

‎mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
package io.modelcontextprotocol.modern.server;
66

7+
import java.time.Duration;
78
import java.util.HashMap;
89
import java.util.List;
910
import java.util.Map;
1011
import java.util.stream.Stream;
1112

1213
import io.modelcontextprotocol.common.McpTransportContext;
1314
import io.modelcontextprotocol.json.McpJsonDefaults;
15+
import io.modelcontextprotocol.modern.JsonRpc.JSONRPCMessage;
1416
import io.modelcontextprotocol.modern.JsonRpc.JSONRPCNotification;
1517
import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest;
1618
import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse;
@@ -32,6 +34,7 @@
3234
import org.junit.jupiter.api.Test;
3335
import org.junit.jupiter.params.ParameterizedTest;
3436
import org.junit.jupiter.params.provider.MethodSource;
37+
import reactor.core.publisher.Flux;
3538
import reactor.core.publisher.Mono;
3639
import reactor.test.StepVerifier;
3740

@@ -283,6 +286,54 @@ void resourceSubscribeIsNotAdvertisedWithoutSubscriptions() {
283286
assertThat(resourcesCapability(server)).containsEntry("subscribe", false);
284287
}
285288

289+
@Test
290+
void changeBroadcastAsSoonAsAckIsDeliveredReachesTheStream() {
291+
McpChangeFeed feed = new McpChangeFeed();
292+
McpServer server = toolsServer(feed);
293+
294+
// Trigger a change from the subscribing thread the change is broadcast right when
295+
// ack is received.
296+
// This simulates high CPU loads in test harnesses, were writing the ack might
297+
// happen
298+
// sooner than any subsequent event subscription.
299+
Flux<JSONRPCMessage> messages = listen(server, 1).messages().doOnNext(msg -> {
300+
if (msg instanceof JSONRPCNotification notification
301+
&& notification.method().equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED)) {
302+
feed.broadcast(new ServerChange.ToolsListChanged());
303+
}
304+
});
305+
306+
// A lost change never arrives: time out instead of hanging.
307+
StepVerifier.create(messages)
308+
.expectNextMatches(msg -> ((JSONRPCNotification) msg).method()
309+
.equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED))
310+
.expectNextMatches(msg -> ((JSONRPCNotification) msg).method()
311+
.equals(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED))
312+
.then(server::closeGracefully)
313+
.expectNextMatches(msg -> msg instanceof JSONRPCResponse)
314+
.expectComplete()
315+
.verify(Duration.ofSeconds(5));
316+
}
317+
318+
@Test
319+
void changeDeliveredBeforeAckIsSentFollowsTheAck() {
320+
// Delivers a change as soon as the listen stream subscribes, before the ack is
321+
// out.
322+
McpChangePublisher publisher = () -> Flux.<ServerChange>just(new ServerChange.ToolsListChanged())
323+
.concatWith(Flux.never());
324+
McpServer server = toolsServer(publisher);
325+
326+
StepVerifier.create(listen(server, 1).messages())
327+
.expectNextMatches(msg -> ((JSONRPCNotification) msg).method()
328+
.equals(McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED))
329+
.expectNextMatches(msg -> ((JSONRPCNotification) msg).method()
330+
.equals(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED))
331+
.then(server::closeGracefully)
332+
.expectNextMatches(msg -> msg instanceof JSONRPCResponse)
333+
.expectComplete()
334+
.verify(Duration.ofSeconds(5));
335+
}
336+
286337
private static McpServer resourcesServer(boolean supportsSubscribe, McpChangePublisher publisher) {
287338
McpAsyncResourceRepository repository = new McpAsyncResourceRepository() {
288339
@Override

0 commit comments

Comments
 (0)