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>
This commit is contained in:
Nicolas Quinquenel 2026-09-04 19:48:19 +02:00 • committed by Dariusz Jędrzejczyk
commit 2bb1481ec3
No known key found for this signature in database
GPG key ID: FD786A4E2335C448
2 changed files with 84 additions and 16 deletions

View file

@ -258,16 +258,13 @@ public class StdioClientTransport implements McpClientTransport {
@Override
public Mono<Void> sendMessage(JSONRPCMessage message) {
if (this.outboundSink.tryEmitNext(message).isSuccess()) {
// TODO: essentially we could reschedule ourselves in some time and make
// another attempt with the already read data but pause reading until
// success
// In this approach we delegate the retry and the backpressure onto the
// caller. This might be enough for most cases.
try {
// busyLooping retries FAIL_NON_SERIALIZED under concurrent senders
this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
return Mono.empty();
}
else {
return Mono.error(new RuntimeException("Failed to enqueue message"));
catch (Sinks.EmissionException e) {
return Mono.error(new RuntimeException("Failed to enqueue message", e));
}
}

View file

@ -10,13 +10,13 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.spec.McpServerSession;
@ -175,11 +175,12 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> {
if (outboundSink.tryEmitNext(message).isSuccess()) {
try {
outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
return Mono.empty();
}
else {
return Mono.error(new RuntimeException("Failed to enqueue message"));
catch (Sinks.EmissionException e) {
return Mono.error(new RuntimeException("Failed to enqueue message", e));
}
}));
}