Skip to content

Commit 840f85c

Browse files
author
Mark Pollack
committed
Isolate the shared timeout timer; keep an event whose write fails
From the cold reviews of the post-merge hardening: - The shared timer kept cancelled timeout tasks queued until they would have fired, and delivered timeout errors on its only thread, so a subscriber blocking in its error handler delayed every other session's timeouts. Cancelled tasks are now removed, and AcpSchedulers.withTimeout hands the TimeoutException to a separate daemon thread. Tested. - An SSE event whose socket write threw was dropped; it now goes back to the front of the mailbox for the next subscriber. Tested. - One connection whose keep-alive throws no longer stops keep-alives for all the others. - StreamableHttpAcpServlet is package-private again: its constructor needs the transport's internal registry, so a public class name would have promised an embedding API that does not exist yet.
1 parent e23cde8 commit 840f85c

8 files changed

Lines changed: 136 additions & 12 deletions

File tree

‎acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public class AcpAgentSession implements AcpSession {
5555
private final Duration requestTimeout;
5656

5757
/**
58-
* Per-session daemon scheduler for timeout operations. Disposed when session closes.
58+
* The JVM-wide daemon timer shared by every session (see AcpSchedulers); never disposed here.
5959
*/
6060
private final Scheduler timeoutScheduler;
6161

@@ -405,7 +405,7 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
405405
this.pendingResponses.remove(requestId);
406406
pendingResponseSink.error(error);
407407
});
408-
})).timeout(this.requestTimeout, timeoutScheduler).handle((jsonRpcResponse, deliveredResponseSink) -> {
408+
})).transform(response -> AcpSchedulers.withTimeout(response, this.requestTimeout)).handle((jsonRpcResponse, deliveredResponseSink) -> {
409409
if (jsonRpcResponse.error() != null) {
410410
logger.error("Error handling request: {}", jsonRpcResponse.error());
411411
deliveredResponseSink.error(new AcpError(jsonRpcResponse.error()));

‎acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public class AcpClientSession implements AcpSession {
5858
private final Duration requestTimeout;
5959

6060
/**
61-
* Per-session daemon scheduler for timeout operations. Disposed when session closes.
61+
* The JVM-wide daemon timer shared by every session (see AcpSchedulers); never disposed here.
6262
*/
6363
private final Scheduler timeoutScheduler;
6464

@@ -418,7 +418,7 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
418418
this.pendingResponses.remove(requestId);
419419
pendingResponseSink.error(error);
420420
});
421-
})).timeout(this.requestTimeout, timeoutScheduler).handle((jsonRpcResponse, deliveredResponseSink) -> {
421+
})).transform(response -> AcpSchedulers.withTimeout(response, this.requestTimeout)).handle((jsonRpcResponse, deliveredResponseSink) -> {
422422
if (jsonRpcResponse.error() != null) {
423423
logger.error("Error handling request: {}", jsonRpcResponse.error());
424424
deliveredResponseSink.error(new AcpError(jsonRpcResponse.error()));

‎acp-core/src/main/java/com/agentclientprotocol/sdk/util/AcpSchedulers.java‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44

55
package com.agentclientprotocol.sdk.util;
66

7+
import java.time.Duration;
78
import java.util.concurrent.Executors;
9+
import java.util.concurrent.ScheduledThreadPoolExecutor;
10+
import java.util.concurrent.TimeoutException;
811

12+
import reactor.core.publisher.Mono;
913
import reactor.core.scheduler.Scheduler;
1014
import reactor.core.scheduler.Schedulers;
1115

@@ -26,11 +30,29 @@ public final class AcpSchedulers {
2630

2731
private static final class TimeoutHolder {
2832

29-
static final Scheduler SCHEDULER = Schedulers.fromExecutorService(Executors.newScheduledThreadPool(1, r -> {
30-
Thread t = new Thread(r, "acp-timeout");
33+
static final Scheduler SCHEDULER = Schedulers.fromExecutorService(timerExecutor(), "acp-timeout");
34+
35+
/**
36+
* Timeout errors are handed off the timer so a subscriber that blocks in its error
37+
* handler cannot delay every other session's timeouts.
38+
*/
39+
static final Scheduler DELIVERY = Schedulers.fromExecutorService(Executors.newCachedThreadPool(r -> {
40+
Thread t = new Thread(r, "acp-timeout-delivery");
3141
t.setDaemon(true);
3242
return t;
33-
}), "acp-timeout");
43+
}), "acp-timeout-delivery");
44+
45+
private static ScheduledThreadPoolExecutor timerExecutor() {
46+
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> {
47+
Thread t = new Thread(r, "acp-timeout");
48+
t.setDaemon(true);
49+
return t;
50+
});
51+
// A request that completes cancels its timeout; drop the task instead of
52+
// keeping it queued until it would have fired (up to the request timeout).
53+
executor.setRemoveOnCancelPolicy(true);
54+
return executor;
55+
}
3456

3557
}
3658

@@ -45,4 +67,16 @@ public static Scheduler timeouts() {
4567
return TimeoutHolder.SCHEDULER;
4668
}
4769

70+
/**
71+
* Applies a timeout on the shared timer and delivers the resulting
72+
* {@link TimeoutException} on a separate daemon thread.
73+
* @param mono the source
74+
* @param timeout how long to wait for its first signal
75+
* @return the source with the timeout applied
76+
*/
77+
public static <T> Mono<T> withTimeout(Mono<T> mono, Duration timeout) {
78+
return mono.timeout(timeout, TimeoutHolder.SCHEDULER)
79+
.onErrorResume(TimeoutException.class, e -> Mono.<T>error(e).subscribeOn(TimeoutHolder.DELIVERY));
80+
}
81+
4882
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2025-2026 the original author or authors.
3+
*/
4+
5+
package com.agentclientprotocol.sdk.util;
6+
7+
import java.time.Duration;
8+
import java.util.concurrent.CountDownLatch;
9+
import java.util.concurrent.TimeUnit;
10+
import java.util.concurrent.TimeoutException;
11+
12+
import org.junit.jupiter.api.Test;
13+
import reactor.core.publisher.Mono;
14+
15+
import static org.assertj.core.api.Assertions.assertThat;
16+
17+
/**
18+
* The shared timeout timer serves every session in the JVM, so one session's error handler
19+
* must not be able to delay another session's timeout.
20+
*/
21+
class AcpSchedulersTest {
22+
23+
@Test
24+
void aBlockingTimeoutHandlerDoesNotDelayAnotherSessionsTimeout() throws Exception {
25+
CountDownLatch release = new CountDownLatch(1);
26+
CountDownLatch firstBlocked = new CountDownLatch(1);
27+
AcpSchedulers.withTimeout(Mono.never(), Duration.ofMillis(20)).subscribe(v -> {
28+
}, error -> {
29+
firstBlocked.countDown();
30+
try {
31+
release.await(5, TimeUnit.SECONDS); // a misbehaving subscriber
32+
}
33+
catch (InterruptedException ignored) {
34+
Thread.currentThread().interrupt();
35+
}
36+
});
37+
assertThat(firstBlocked.await(2, TimeUnit.SECONDS)).isTrue();
38+
39+
long start = System.nanoTime();
40+
CountDownLatch secondTimedOut = new CountDownLatch(1);
41+
AcpSchedulers.withTimeout(Mono.never(), Duration.ofMillis(50)).subscribe(v -> {
42+
}, error -> {
43+
if (error instanceof TimeoutException) {
44+
secondTimedOut.countDown();
45+
}
46+
});
47+
try {
48+
assertThat(secondTimedOut.await(2, TimeUnit.SECONDS)).isTrue();
49+
assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofMillis(1000));
50+
}
51+
finally {
52+
release.countDown();
53+
}
54+
}
55+
56+
}

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStream.java‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,24 @@ void drain() {
190190
flushIfReady();
191191
while (!detached && output.isReady()) {
192192
byte[] bytes = comments.pollFirst();
193+
String payload = null;
193194
if (bytes == null) {
194-
String payload = mailbox.pollFirst();
195+
payload = mailbox.pollFirst();
195196
if (payload == null) {
196197
break;
197198
}
198199
bytes = ("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8);
199200
}
200-
output.write(bytes);
201+
try {
202+
output.write(bytes);
203+
}
204+
catch (IOException | IllegalStateException e) {
205+
if (payload != null) {
206+
// The write failed, so the event did not go out: keep it first in line.
207+
mailbox.addFirst(payload);
208+
}
209+
throw e;
210+
}
201211
flushPending = true;
202212
}
203213
flushIfReady();

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,15 @@ private void startKeepAlive() {
247247
// A comment every interval keeps proxies from cutting idle streams and surfaces
248248
// dead subscribers (the write fails) without waiting for the next real event.
249249
this.keepAliveTask = Flux.interval(interval, interval, scheduler)
250-
.subscribe(tick -> connections.values().forEach(StreamableHttpConnection::keepAlive));
250+
.subscribe(tick -> connections.values().forEach(connection -> {
251+
try {
252+
connection.keepAlive();
253+
}
254+
catch (RuntimeException e) {
255+
// One broken connection must not stop keep-alives for every other one.
256+
logger.debug("Keep-alive failed for connection {}: {}", connection.id(), e.getMessage());
257+
}
258+
}));
251259
}
252260

253261
private void stopKeepAlive() {

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
*
4848
* @author Kaiser Dandangi
4949
*/
50-
public class StreamableHttpAcpServlet extends HttpServlet {
50+
class StreamableHttpAcpServlet extends HttpServlet {
5151

5252
private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpServlet.class);
5353

‎acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStreamTest.java‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ void anEventPushedWhileTheSubscriberIsClosingIsKept() throws IOException {
139139
assertThat(next.output.written()).isEqualTo(OPEN + "data: \"x\"\n\n");
140140
}
141141

142+
@Test
143+
void anEventWhoseWriteFailsIsKeptForTheNextSubscriber() throws IOException {
144+
SseOutboundStream stream = new SseOutboundStream(8, 8);
145+
Attached broken = attach(stream, true);
146+
broken.output.failWrites = true;
147+
stream.push("\"lost?\"");
148+
149+
Attached next = attach(stream, true);
150+
assertThat(next.output.written()).isEqualTo(OPEN + "data: \"lost?\"\n\n");
151+
}
152+
142153
private static Attached attach(SseOutboundStream stream, boolean ready) throws IOException {
143154
FakeOutput output = new FakeOutput(ready);
144155
AsyncContext asyncContext = mock(AsyncContext.class);
@@ -177,8 +188,13 @@ public void setWriteListener(WriteListener writeListener) {
177188
this.listener = writeListener;
178189
}
179190

191+
boolean failWrites;
192+
180193
@Override
181-
public void write(int b) {
194+
public void write(int b) throws IOException {
195+
if (failWrites) {
196+
throw new IOException("broken pipe");
197+
}
182198
bytes.write(b);
183199
}
184200

0 commit comments

Comments
 (0)