@@ -79,6 +79,7 @@ public class KimiAcpClient implements AutoCloseable {
7979 private final Map <String , PromptStream > promptStreams = new ConcurrentHashMap <String , PromptStream >();
8080 private final AtomicLong rpcIds = new AtomicLong ();
8181 private final AtomicBoolean closed = new AtomicBoolean (false );
82+ private final AtomicBoolean connected = new AtomicBoolean (false );
8283 private final ScheduledExecutorService timer = Executors .newSingleThreadScheduledExecutor (r -> {
8384 Thread thread = new Thread (r , "kimi-acp-timer" );
8485 thread .setDaemon (true );
@@ -109,6 +110,13 @@ public KimiAcpClient(KimiAcpConfig config) {
109110 * fails or times out.
110111 */
111112 public String connect () {
113+ if (closed .get ()) {
114+ throw new IllegalStateException ("kimi acp client is closed" );
115+ }
116+ // CAS guard: a second connect would orphan the first child process.
117+ if (!connected .compareAndSet (false , true )) {
118+ throw new IllegalStateException ("kimi acp client is already connected" );
119+ }
112120 List <String > command = new ArrayList <String >();
113121 command .add (config .getLocalExecutable ());
114122 if (config .getAcpSubcommand () != null ) {
@@ -124,6 +132,7 @@ public String connect() {
124132 try {
125133 process = builder .start ();
126134 } catch (IOException e ) {
135+ connected .set (false );
127136 throw new KimiException ("Failed to spawn kimi acp: " + config .getLocalExecutable (), e );
128137 }
129138 stdin = new PrintWriter (new OutputStreamWriter (process .getOutputStream (), StandardCharsets .UTF_8 ), true );
@@ -135,14 +144,27 @@ public String connect() {
135144 params .put ("protocolVersion" , Integer .valueOf (1 ));
136145 Map <String , Object > clientCaps = new LinkedHashMap <String , Object >();
137146 params .put ("clientCapabilities" , clientCaps );
138- JsonNode result = await (request ("initialize" , params ), config .getConnectTimeoutMillis (), "initialize" );
139- if (result .hasNonNull ("protocolVersion" )) {
140- protocolVersion = result .path ("protocolVersion" ).asText (null );
141- }
142- if (result .hasNonNull ("agentInfo" )) {
143- agentVersion = result .path ("agentInfo" ).path ("version" ).asText (null );
147+ try {
148+ JsonNode result = await (request ("initialize" , params ), config .getConnectTimeoutMillis (), "initialize" );
149+ if (result .hasNonNull ("protocolVersion" )) {
150+ protocolVersion = result .path ("protocolVersion" ).asText (null );
151+ }
152+ if (result .hasNonNull ("agentInfo" )) {
153+ agentVersion = result .path ("agentInfo" ).path ("version" ).asText (null );
154+ }
155+ return agentVersion ;
156+ } catch (RuntimeException e ) {
157+ // Handshake failure leaves the child alive — destroy it here so a
158+ // discarded client cannot leak the process, and allow a retry.
159+ Process current = process ;
160+ if (current != null ) {
161+ current .destroy ();
162+ }
163+ process = null ;
164+ stdin = null ;
165+ connected .set (false );
166+ throw e ;
144167 }
145- return agentVersion ;
146168 }
147169
148170 /**
0 commit comments