2020import java .io .IOException ;
2121import java .io .InputStreamReader ;
2222import java .nio .charset .StandardCharsets ;
23+ import java .time .Duration ;
2324import java .util .ArrayList ;
2425import java .util .List ;
2526import java .util .concurrent .CompletableFuture ;
@@ -77,6 +78,8 @@ public class StdioClientTransport implements McpTransport {
7778
7879 private final Sinks .Many <String > errorSink ;
7980
81+ private volatile boolean isClosing = false ;
82+
8083 // visible for tests
8184 private Consumer <String > errorHandler = error -> logger .error ("Error received: {}" , error );
8285
@@ -200,19 +203,32 @@ private void startErrorProcessing() {
200203 try (BufferedReader processErrorReader = new BufferedReader (
201204 new InputStreamReader (process .getErrorStream ()))) {
202205 String line ;
203- while ((line = processErrorReader .readLine ()) != null ) {
206+ while (! isClosing && (line = processErrorReader .readLine ()) != null ) {
204207 try {
205208 logger .error ("Received error line: {}" , line );
206- // TODO: handle errors, etc.
207- this .errorSink .tryEmitNext (line );
209+ if (!this .errorSink .tryEmitNext (line ).isSuccess ()) {
210+ if (!isClosing ) {
211+ logger .error ("Failed to emit error message" );
212+ }
213+ break ;
214+ }
208215 }
209216 catch (Exception e ) {
210- throw new RuntimeException (e );
217+ if (!isClosing ) {
218+ logger .error ("Error processing error message" , e );
219+ }
220+ break ;
211221 }
212222 }
213223 }
214224 catch (IOException e ) {
215- throw new RuntimeException (e );
225+ if (!isClosing ) {
226+ logger .error ("Error reading from error stream" , e );
227+ }
228+ }
229+ finally {
230+ isClosing = true ;
231+ errorSink .tryEmitComplete ();
216232 }
217233 });
218234 }
@@ -254,21 +270,32 @@ private void startInboundProcessing() {
254270 this .inboundScheduler .schedule (() -> {
255271 try (BufferedReader processReader = new BufferedReader (new InputStreamReader (process .getInputStream ()))) {
256272 String line ;
257- while ((line = processReader .readLine ()) != null ) {
273+ while (! isClosing && (line = processReader .readLine ()) != null ) {
258274 try {
259275 JSONRPCMessage message = McpSchema .deserializeJsonRpcMessage (this .objectMapper , line );
260276 if (!this .inboundSink .tryEmitNext (message ).isSuccess ()) {
261- // TODO: Back off, reschedule, give up?
262- throw new RuntimeException ("Failed to enqueue message" );
277+ if (!isClosing ) {
278+ logger .error ("Failed to enqueue inbound message" );
279+ }
280+ break ;
263281 }
264282 }
265283 catch (Exception e ) {
266- throw new RuntimeException (e );
284+ if (!isClosing ) {
285+ logger .error ("Error processing inbound message" , e );
286+ }
287+ break ;
267288 }
268289 }
269290 }
270291 catch (IOException e ) {
271- throw new RuntimeException (e );
292+ if (!isClosing ) {
293+ logger .error ("Error reading from input stream" , e );
294+ }
295+ }
296+ finally {
297+ isClosing = true ;
298+ inboundSink .tryEmitComplete ();
272299 }
273300 });
274301 }
@@ -284,7 +311,7 @@ private void startOutboundProcessing() {
284311 // want to ensure that the actual writing happens on a dedicated thread
285312 .publishOn (outboundScheduler )
286313 .handle ((message , s ) -> {
287- if (message != null ) {
314+ if (message != null && ! isClosing ) {
288315 try {
289316 String jsonMessage = objectMapper .writeValueAsString (message );
290317 // Escape any embedded newlines in the JSON message as per spec:
@@ -293,9 +320,12 @@ private void startOutboundProcessing() {
293320 // embedded newlines.
294321 jsonMessage = jsonMessage .replace ("\r \n " , "\\ n" ).replace ("\n " , "\\ n" ).replace ("\r " , "\\ n" );
295322
296- this .process .getOutputStream ().write (jsonMessage .getBytes (StandardCharsets .UTF_8 ));
297- this .process .getOutputStream ().write ("\n " .getBytes (StandardCharsets .UTF_8 ));
298- this .process .getOutputStream ().flush ();
323+ var os = this .process .getOutputStream ();
324+ synchronized (os ) {
325+ os .write (jsonMessage .getBytes (StandardCharsets .UTF_8 ));
326+ os .write ("\n " .getBytes (StandardCharsets .UTF_8 ));
327+ os .flush ();
328+ }
299329 s .next (message );
300330 }
301331 catch (IOException e ) {
@@ -306,7 +336,16 @@ private void startOutboundProcessing() {
306336 }
307337
308338 protected void handleOutbound (Function <Flux <JSONRPCMessage >, Flux <JSONRPCMessage >> outboundConsumer ) {
309- outboundConsumer .apply (outboundSink .asFlux ()).subscribe ();
339+ outboundConsumer .apply (outboundSink .asFlux ()).doOnComplete (() -> {
340+ isClosing = true ;
341+ outboundSink .tryEmitComplete ();
342+ }).doOnError (e -> {
343+ if (!isClosing ) {
344+ logger .error ("Error in outbound processing" , e );
345+ isClosing = true ;
346+ outboundSink .tryEmitComplete ();
347+ }
348+ }).subscribe ();
310349 }
311350
312351 /**
@@ -317,7 +356,18 @@ protected void handleOutbound(Function<Flux<JSONRPCMessage>, Flux<JSONRPCMessage
317356 */
318357 @ Override
319358 public Mono <Void > closeGracefully () {
320- return Mono .fromFuture (() -> {
359+ return Mono .fromRunnable (() -> {
360+ isClosing = true ;
361+ logger .debug ("Initiating graceful shutdown" );
362+ }).then (Mono .defer (() -> {
363+ // First complete all sinks to stop accepting new messages
364+ inboundSink .tryEmitComplete ();
365+ outboundSink .tryEmitComplete ();
366+ errorSink .tryEmitComplete ();
367+
368+ // Give a short time for any pending messages to be processed
369+ return Mono .delay (Duration .ofMillis (100 ));
370+ })).then (Mono .fromFuture (() -> {
321371 logger .info ("Sending TERM to process" );
322372 if (this .process != null ) {
323373 this .process .destroy ();
@@ -326,16 +376,23 @@ public Mono<Void> closeGracefully() {
326376 else {
327377 return CompletableFuture .failedFuture (new RuntimeException ("Process not started" ));
328378 }
329- }).doOnNext (process -> {
379+ })) .doOnNext (process -> {
330380 if (process .exitValue () != 0 ) {
331381 logger .warn ("Process terminated with code " + process .exitValue ());
332382 }
333383 }).then (Mono .fromRunnable (() -> {
334- // The Threads are blocked on readLine so disposeGracefully would not
335- // interrupt them, therefore we issue an async hard dispose.
336- inboundScheduler .dispose ();
337- errorScheduler .dispose ();
338- outboundScheduler .dispose ();
384+ try {
385+ // The Threads are blocked on readLine so disposeGracefully would not
386+ // interrupt them, therefore we issue an async hard dispose.
387+ inboundScheduler .dispose ();
388+ errorScheduler .dispose ();
389+ outboundScheduler .dispose ();
390+
391+ logger .info ("Graceful shutdown completed" );
392+ }
393+ catch (Exception e ) {
394+ logger .error ("Error during graceful shutdown" , e );
395+ }
339396 })).then ().subscribeOn (Schedulers .boundedElastic ());
340397 }
341398
0 commit comments