5757 * {@link #setStdErrorHandler}.
5858 * </p>
5959 *
60+ * <p>
61+ * {@link #closeGracefully()} ends the agent process in steps. It closes the agent's standard
62+ * input and waits up to {@value #END_OF_INPUT_WAIT_MILLIS} ms for the agent to exit by itself,
63+ * as an ACP stdio agent does at the end of its input (an agent built with this SDK answers what
64+ * it received, then exits 0). An agent still running then is sent SIGTERM ({@link
65+ * Process#destroy()}; on POSIX systems it exits 143 unless it handles the signal) and given
66+ * five more seconds, and is then killed (exit 137). The end is logged once, at INFO, however
67+ * often the transport is closed: {@code closeGracefully()} followed by {@code close()}, as
68+ * try-with-resources does, does not stop the agent twice.
69+ * </p>
70+ *
6071 * @author Mark Pollack
6172 * @author Christian Tzolov (MCP Java SDK)
6273 * @author Dariusz Jędrzejczyk (MCP Java SDK)
@@ -114,6 +125,18 @@ public class StdioAcpClientTransport implements AcpClientTransport {
114125 */
115126 private final AtomicBoolean isConnected = new AtomicBoolean (false );
116127
128+ /**
129+ * How long {@link #closeGracefully()} waits, after closing the agent's standard input, for
130+ * the agent to exit by itself before it sends SIGTERM: {@value} ms.
131+ */
132+ public static final long END_OF_INPUT_WAIT_MILLIS = 2_000 ;
133+
134+ /** How long to wait for the agent process to exit after SIGTERM before killing it. */
135+ private static final Duration TERM_WAIT = Duration .ofSeconds (5 );
136+
137+ /** Set by the first {@link #closeGracefully}: the agent process is stopped once. */
138+ private final AtomicBoolean stopping = new AtomicBoolean (false );
139+
117140 /** How long to wait for the agent process to exit after its standard output ends. */
118141 private static final Duration EXIT_WAIT = Duration .ofSeconds (5 );
119142
@@ -495,14 +518,19 @@ protected void handleOutbound(Function<Flux<JSONRPCMessage>, Flux<JSONRPCMessage
495518 }
496519
497520 /**
498- * Gracefully closes the transport by destroying the process and disposing of the
499- * schedulers. This method sends a TERM signal to the process and waits for it to exit
500- * before cleaning up resources.
521+ * Gracefully closes the transport: closes the agent's standard input, waits up to
522+ * {@value #END_OF_INPUT_WAIT_MILLIS} ms for the agent to exit by itself, then sends it
523+ * SIGTERM and, five seconds later, kills it; then disposes of the schedulers. Closing an
524+ * already closed transport does nothing more.
501525 * @return A Mono that completes when the transport is closed
502526 */
503527 @ Override
504528 public Mono <Void > closeGracefully () {
505529 return Mono .fromRunnable (() -> {
530+ if (!this .stopping .compareAndSet (false , true )) {
531+ logger .debug ("Already closed" );
532+ return ;
533+ }
506534 closedLocally = true ;
507535 isClosing = true ;
508536 logger .debug ("Initiating graceful shutdown" );
@@ -512,7 +540,7 @@ public Mono<Void> closeGracefully() {
512540 outboundSink .tryEmitComplete ();
513541 errorSink .tryEmitComplete ();
514542
515- // Destroy process FIRST - this closes streams and unblocks readLine()
543+ // Stop the process FIRST - its end closes the streams and unblocks readLine()
516544 Process process = this .process ;
517545 if (process != null ) {
518546 stop (process );
@@ -533,15 +561,29 @@ public Mono<Void> closeGracefully() {
533561 }
534562
535563 /**
536- * Sends TERM to the process and waits up to five seconds for it to exit, then kills it.
537- * Waits with a blocking waitFor() rather than Mono.fromFuture(process.onExit()), which
538- * would run on ForkJoinPool.commonPool.
564+ * Closes the process's standard input and waits for it to exit by itself; then sends TERM
565+ * and waits up to five seconds, then kills it. Waits with a blocking waitFor() rather than
566+ * Mono.fromFuture(process.onExit()), which would run on ForkJoinPool.commonPool.
539567 */
540568 private static void stop (Process process ) {
541- logger .debug ("Sending TERM to process" );
542- process .destroy ();
543569 try {
544- if (process .waitFor (5 , java .util .concurrent .TimeUnit .SECONDS )) {
570+ var stdin = process .getOutputStream ();
571+ synchronized (stdin ) {
572+ stdin .close ();
573+ }
574+ }
575+ catch (IOException e ) {
576+ logger .debug ("Closing the agent's standard input failed: {}" , e .getMessage ());
577+ }
578+ try {
579+ if (process .waitFor (END_OF_INPUT_WAIT_MILLIS , java .util .concurrent .TimeUnit .MILLISECONDS )) {
580+ logExit (process .exitValue ());
581+ return ;
582+ }
583+ logger .debug ("Agent still running {} ms after the end of its input; sending TERM" ,
584+ END_OF_INPUT_WAIT_MILLIS );
585+ process .destroy ();
586+ if (process .waitFor (TERM_WAIT .toMillis (), java .util .concurrent .TimeUnit .MILLISECONDS )) {
545587 logExit (process .exitValue ());
546588 }
547589 else {
0 commit comments