Skip to content

Commit 2bb1481

Browse files
nquinquenelchemicL
authored andcommitted
Make STDIO transport sendMessage thread-safe (#304)
Concurrent calls to `sendMessage` on the STDIO transports raced on the unicast sink and sporadically failed with `FAIL_NON_SERIALIZED`, dropping the message with `RuntimeException("Failed to enqueue message")`. Switch `tryEmitNext` to `emitNext` with a `busyLooping` EmitFailureHandler so concurrent emissions are serialized as recommended by the Reactor maintainers. Applied on both `StdioServerTransportProvider` and `StdioClientTransport`. Co-authored-by: Nicolas Quinquenel <nicolas.quinquenel@sonarsource.com> Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
1 parent 735a851 commit 2bb1481

3 files changed

Lines changed: 84 additions & 16 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,16 +258,13 @@ private void handleIncomingErrors() {
258258

259259
@Override
260260
public Mono<Void> sendMessage(JSONRPCMessage message) {
261-
if (this.outboundSink.tryEmitNext(message).isSuccess()) {
262-
// TODO: essentially we could reschedule ourselves in some time and make
263-
// another attempt with the already read data but pause reading until
264-
// success
265-
// In this approach we delegate the retry and the backpressure onto the
266-
// caller. This might be enough for most cases.
261+
try {
262+
// busyLooping retries FAIL_NON_SERIALIZED under concurrent senders
263+
this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
267264
return Mono.empty();
268265
}
269-
else {
270-
return Mono.error(new RuntimeException("Failed to enqueue message"));
266+
catch (Sinks.EmissionException e) {
267+
return Mono.error(new RuntimeException("Failed to enqueue message", e));
271268
}
272269
}
273270

‎mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
import java.io.InputStreamReader;
1111
import java.io.OutputStream;
1212
import java.nio.charset.StandardCharsets;
13+
import java.time.Duration;
1314
import java.util.List;
1415
import java.util.concurrent.Executors;
1516
import java.util.concurrent.atomic.AtomicBoolean;
1617
import java.util.function.Function;
1718

1819
import io.modelcontextprotocol.json.TypeRef;
19-
import io.modelcontextprotocol.spec.McpError;
2020
import io.modelcontextprotocol.spec.McpSchema;
2121
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
2222
import io.modelcontextprotocol.spec.McpServerSession;
@@ -175,11 +175,12 @@ public StdioMcpSessionTransport() {
175175
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
176176

177177
return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> {
178-
if (outboundSink.tryEmitNext(message).isSuccess()) {
178+
try {
179+
outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
179180
return Mono.empty();
180181
}
181-
else {
182-
return Mono.error(new RuntimeException("Failed to enqueue message"));
182+
catch (Sinks.EmissionException e) {
183+
return Mono.error(new RuntimeException("Failed to enqueue message", e));
183184
}
184185
}));
185186
}

‎mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
import java.io.BufferedReader;
88
import java.io.ByteArrayInputStream;
99
import java.io.ByteArrayOutputStream;
10+
import java.io.FilterOutputStream;
11+
import java.io.IOException;
1012
import java.io.InputStream;
1113
import java.io.InputStreamReader;
14+
import java.io.OutputStream;
1215
import java.io.PrintStream;
1316
import java.nio.charset.StandardCharsets;
1417
import java.time.Duration;
@@ -25,7 +28,9 @@
2528
import org.junit.jupiter.api.AfterEach;
2629
import org.junit.jupiter.api.BeforeEach;
2730
import org.junit.jupiter.api.Test;
31+
import reactor.core.publisher.Flux;
2832
import reactor.core.publisher.Mono;
33+
import reactor.core.scheduler.Schedulers;
2934
import reactor.test.StepVerifier;
3035

3136
import static org.assertj.core.api.Assertions.assertThat;
@@ -99,7 +104,7 @@ void shouldCreateSessionWhenSessionFactoryIsSet() {
99104
}
100105

101106
@Test
102-
void shouldHandleIncomingMessages() throws Exception {
107+
void shouldHandleIncomingMessages() {
103108

104109
String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}\n";
105110
InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8));
@@ -229,7 +234,7 @@ void shouldHandleNotificationBeforeSessionFactoryIsSet() {
229234
}
230235

231236
@Test
232-
void shouldHandleInvalidJsonMessage() throws Exception {
237+
void shouldHandleInvalidJsonMessage() {
233238

234239
// Write an invalid JSON message to the input stream
235240
String jsonMessage = "{invalid json}\n";
@@ -248,7 +253,7 @@ void shouldHandleInvalidJsonMessage() throws Exception {
248253
}
249254

250255
@Test
251-
void shouldRejectInboundMessageExceedingMaxSize() throws Exception {
256+
void shouldRejectInboundMessageExceedingMaxSize() {
252257
// A line larger than the configured limit that never terminates with a newline.
253258
// BufferedReader#readLine would buffer the whole thing; the bounded reader must
254259
// abort instead.
@@ -291,7 +296,7 @@ void shouldRejectNonPositiveMaxSize() {
291296
}
292297

293298
@Test
294-
void shouldHandleSessionClose() throws Exception {
299+
void shouldHandleSessionClose() {
295300
// Set session factory
296301
transportProvider.setSessionFactory(sessionFactory);
297302

@@ -302,4 +307,69 @@ void shouldHandleSessionClose() throws Exception {
302307
verify(mockSession).closeGracefully();
303308
}
304309

310+
@Test
311+
void shouldHandleConcurrentSendMessage() throws Exception {
312+
int messageCount = 500;
313+
ByteArrayOutputStream output = new ByteArrayOutputStream();
314+
CountDownLatch writtenMessages = new CountDownLatch(messageCount);
315+
316+
// Redirect the transport output to a buffer so we can verify every message lands.
317+
// Writes happen serially on the outbound scheduler, so count the
318+
// newline delimiters as they are written.
319+
OutputStream countingOutput = new FilterOutputStream(output) {
320+
321+
@Override
322+
public void write(int b) throws IOException {
323+
this.out.write(b);
324+
if (b == '\n') {
325+
writtenMessages.countDown();
326+
}
327+
}
328+
329+
@Override
330+
public void write(byte[] b, int off, int len) throws IOException {
331+
this.out.write(b, off, len);
332+
for (int i = off; i < off + len; i++) {
333+
if (b[i] == '\n') {
334+
writtenMessages.countDown();
335+
}
336+
}
337+
}
338+
};
339+
transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, countingOutput);
340+
341+
// Capture the inner McpServerTransport handed to the session factory
342+
AtomicReference<McpServerTransport> transportRef = new AtomicReference<>();
343+
McpServerSession.Factory capturingFactory = transport -> {
344+
transportRef.set(transport);
345+
return mockSession;
346+
};
347+
348+
transportProvider.setSessionFactory(capturingFactory);
349+
350+
McpServerTransport transport = transportRef.get();
351+
assertThat(transport).isNotNull();
352+
353+
// Fan sendMessage out across 16 parallel rails to race against the unicast sink
354+
Flux<Integer> concurrentSends = Flux.range(0, messageCount)
355+
.parallel(16)
356+
.runOn(Schedulers.parallel())
357+
.flatMap(i -> transport
358+
.sendMessage(
359+
new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, "test/notification", Map.of()))
360+
.thenReturn(i))
361+
.sequential();
362+
363+
// Every send should complete successfully (no FAIL_NON_SERIALIZED errors)
364+
StepVerifier.create(concurrentSends).expectNextCount(messageCount).verifyComplete();
365+
366+
// Wait until the outbound scheduler has actually written all of them
367+
assertThat(writtenMessages.await(30, TimeUnit.SECONDS))
368+
.as("all %d messages written, %d still missing", messageCount, writtenMessages.getCount())
369+
.isTrue();
370+
371+
// Every message was written as its own newline-delimited JSON line
372+
assertThat(output.toString(StandardCharsets.UTF_8).lines().count()).isEqualTo(messageCount);
373+
}
374+
305375
}

0 commit comments

Comments
 (0)