Skip to content

Commit 24a51ac

Browse files
committed
fix(acp): harden pending RPC and prompt lifecycle
1 parent e247907 commit 24a51ac

1 file changed

Lines changed: 56 additions & 9 deletions

File tree

‎src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,10 @@ public CompletableFuture<KimiAcpTurnResult> promptAsync(String sessionId, String
299299
throw new IllegalStateException("kimi acp client is closed");
300300
}
301301
PromptStream stream = new PromptStream(sessionId, onDelta);
302-
promptStreams.put(sessionId, stream);
302+
PromptStream active = promptStreams.putIfAbsent(sessionId, stream);
303+
if (active != null) {
304+
throw new KimiException("kimi acp session already has an active prompt: " + sessionId);
305+
}
303306
Map<String, Object> content = new LinkedHashMap<String, Object>();
304307
content.put("type", "text");
305308
content.put("text", text);
@@ -308,14 +311,24 @@ public CompletableFuture<KimiAcpTurnResult> promptAsync(String sessionId, String
308311
Map<String, Object> params = new LinkedHashMap<String, Object>();
309312
params.put("sessionId", sessionId);
310313
params.put("prompt", blocks);
311-
CompletableFuture<JsonNode> response = request("session/prompt", params);
314+
final CompletableFuture<JsonNode> response;
315+
try {
316+
response = request("session/prompt", params);
317+
} catch (RuntimeException e) {
318+
promptStreams.remove(sessionId, stream);
319+
throw e;
320+
}
312321
CompletableFuture<KimiAcpTurnResult> future = response.thenApply(node -> {
322+
RuntimeException callbackFailure = stream.callbackFailure();
323+
if (callbackFailure != null) {
324+
throw new KimiException("kimi acp prompt callback failed", callbackFailure);
325+
}
313326
String stopReason = firstText(node, "stopReason", "stop_reason");
314327
return new KimiAcpTurnResult(sessionId, stopReason, stream.content());
315328
});
316329
scheduleTimeout(future, config.getReadTimeoutMillis(), "session/prompt turn");
317330
future.whenComplete((r, error) -> {
318-
promptStreams.remove(sessionId);
331+
promptStreams.remove(sessionId, stream);
319332
if (error != null) {
320333
response.completeExceptionally(error);
321334
}
@@ -427,8 +440,15 @@ public void close() {
427440
if (!closed.compareAndSet(false, true)) {
428441
return;
429442
}
443+
connected.set(false);
430444
timer.shutdownNow();
445+
PrintWriter writer = stdin;
446+
stdin = null;
447+
if (writer != null) {
448+
writer.close();
449+
}
431450
Process current = process;
451+
process = null;
432452
if (current != null) {
433453
current.destroy();
434454
}
@@ -447,8 +467,16 @@ private CompletableFuture<JsonNode> request(String method, Map<String, Object> p
447467
payload.put("method", method);
448468
payload.put("params", params);
449469
CompletableFuture<JsonNode> future = new CompletableFuture<JsonNode>();
450-
pendingRpcs.put(Long.valueOf(id), future);
451-
writeJson(payload);
470+
Long rpcId = Long.valueOf(id);
471+
pendingRpcs.put(rpcId, future);
472+
future.whenComplete((result, error) -> pendingRpcs.remove(rpcId, future));
473+
try {
474+
writeJson(payload);
475+
} catch (RuntimeException e) {
476+
pendingRpcs.remove(rpcId, future);
477+
future.completeExceptionally(e);
478+
throw e;
479+
}
452480
return future;
453481
}
454482

@@ -508,6 +536,16 @@ private void readLoop() {
508536
if (!closed.get()) {
509537
failAllPending(new KimiException("kimi acp stdout read failed", e));
510538
}
539+
} catch (KimiException e) {
540+
if (!closed.get()) {
541+
failAllPending(e);
542+
Process current = process;
543+
if (current != null) {
544+
current.destroy();
545+
}
546+
}
547+
} finally {
548+
connected.set(false);
511549
}
512550
}
513551

@@ -516,8 +554,7 @@ private void handleFrame(String frame) {
516554
try {
517555
node = mapper.readTree(frame);
518556
} catch (Exception ex) {
519-
log.warn("Ignored non-JSON frame from kimi acp");
520-
return;
557+
throw new KimiException("kimi acp protocol received invalid JSON frame", ex);
521558
}
522559
if (node.hasNonNull("id")) {
523560
CompletableFuture<JsonNode> pending = pendingRpcs.remove(Long.valueOf(node.get("id").asLong()));
@@ -631,6 +668,7 @@ private final class PromptStream {
631668
private final StringBuilder content = new StringBuilder();
632669
private final Consumer<String> onDelta;
633670
private boolean truncationWarned;
671+
private volatile RuntimeException callbackFailure;
634672

635673
PromptStream(String sessionId, Consumer<String> onDelta) {
636674
this.onDelta = onDelta;
@@ -653,11 +691,20 @@ void append(String text) {
653691
}
654692
}
655693
content.append(applied);
656-
if (!applied.isEmpty() && onDelta != null) {
657-
onDelta.accept(applied);
694+
if (!applied.isEmpty() && onDelta != null && callbackFailure == null) {
695+
try {
696+
onDelta.accept(applied);
697+
} catch (RuntimeException e) {
698+
callbackFailure = e;
699+
log.warn("kimi acp prompt callback failed; transport remains active");
700+
}
658701
}
659702
}
660703

704+
RuntimeException callbackFailure() {
705+
return callbackFailure;
706+
}
707+
661708
String content() {
662709
return content.toString();
663710
}

0 commit comments

Comments
 (0)