Skip to content

Commit abdfd7e

Browse files
author
Mark Pollack
committed
Bound and keep alive the Streamable HTTP agent transport
StreamableHttpAcpAgentTransportOptions makes every network-facing allocation finite: POST bodies are capped (16 MiB by default, 413 beyond), the WebSocket send queue and the number of provisional session/load streams per connection are bounded, and the SSE mailbox and per-subscriber queue limits are configurable. A failed session/load discards its provisional state. Attached SSE streams receive a keep-alive comment every 15 s so proxies do not cut idle connections and dead subscribers are found without waiting for the next event. A new GET on a stream takes it over from a subscriber the server may not yet know is dead, instead of fanning out duplicates; the Rust and TypeScript servers answer 409, which is harder on a reconnecting client. From the cold reviews of PR #7.
1 parent 6055787 commit abdfd7e

3 files changed

Lines changed: 350 additions & 15 deletions

File tree

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

Lines changed: 132 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler;
5757
import org.slf4j.Logger;
5858
import org.slf4j.LoggerFactory;
59+
import reactor.core.Disposable;
60+
import reactor.core.publisher.Flux;
61+
import reactor.core.scheduler.Scheduler;
62+
import reactor.core.scheduler.Schedulers;
5963
import reactor.core.publisher.Mono;
6064
import reactor.core.publisher.Sinks;
6165

@@ -94,9 +98,7 @@ public class StreamableHttpAcpAgentTransport {
9498

9599
private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream";
96100

97-
private static final int MAX_REPLAY_EVENTS = 1024;
98-
99-
private static final int MAX_PENDING_SSE_EVENTS = 1024;
101+
private static final byte[] SSE_KEEP_ALIVE_COMMENT = ": keep-alive\n\n".getBytes(StandardCharsets.UTF_8);
100102

101103
// Commits the SSE response when there are no replay events, without emitting an ACP message.
102104
private static final byte[] SSE_OPEN_COMMENT = ": connected\n\n".getBytes(StandardCharsets.UTF_8);
@@ -164,6 +166,12 @@ private record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestSc
164166

165167
private final AcpAgentFactory agentFactory;
166168

169+
private final StreamableHttpAcpAgentTransportOptions options;
170+
171+
private volatile Scheduler keepAliveScheduler;
172+
173+
private volatile Disposable keepAliveTask;
174+
167175
private final ConcurrentMap<String, ConnectionState> connections = new ConcurrentHashMap<>();
168176

169177
private final ConcurrentMap<String, WebSocketConnectionState> webSocketConnections = new ConcurrentHashMap<>();
@@ -197,14 +205,29 @@ public StreamableHttpAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAg
197205
*/
198206
public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper,
199207
AcpAgentFactory agentFactory) {
208+
this(port, path, jsonMapper, agentFactory, StreamableHttpAcpAgentTransportOptions.defaults());
209+
}
210+
211+
/**
212+
* Creates a new Streamable HTTP listener with explicit limits.
213+
* @param port port to listen on
214+
* @param path endpoint path
215+
* @param jsonMapper JSON mapper used for serialization
216+
* @param agentFactory factory used to create one agent runtime per connection
217+
* @param options bounds and timings
218+
*/
219+
public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper,
220+
AcpAgentFactory agentFactory, StreamableHttpAcpAgentTransportOptions options) {
200221
Assert.isTrue(port > 0, "Port must be positive");
201222
Assert.hasText(path, "Path must not be empty");
202223
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
203224
Assert.notNull(agentFactory, "The agentFactory can not be null");
225+
Assert.notNull(options, "The options can not be null");
204226
this.configuredPort = port;
205227
this.path = path;
206228
this.jsonMapper = jsonMapper;
207229
this.agentFactory = agentFactory;
230+
this.options = options;
208231
}
209232

210233
/**
@@ -252,6 +275,7 @@ public Mono<Void> start() {
252275
jettyServer.start();
253276
this.server = jettyServer;
254277
this.connector = jettyConnector;
278+
startKeepAlive();
255279
logger.info("Streamable HTTP agent listener started on port {} at path {}", getPort(), path);
256280
return null;
257281
}).then();
@@ -289,7 +313,32 @@ public Mono<Void> closeGracefully() {
289313
});
290314
}
291315

316+
private void startKeepAlive() {
317+
Duration interval = options.keepAliveInterval();
318+
if (interval.isZero()) {
319+
return;
320+
}
321+
Scheduler scheduler = Schedulers.newSingle("acp-streamable-http-keepalive", true);
322+
this.keepAliveScheduler = scheduler;
323+
// A comment every interval keeps proxies from cutting idle streams and surfaces
324+
// dead subscribers (the write fails) without waiting for the next real event.
325+
this.keepAliveTask = Flux.interval(interval, interval, scheduler)
326+
.subscribe(tick -> connections.values().forEach(ConnectionState::keepAlive));
327+
}
328+
329+
private void stopKeepAlive() {
330+
Disposable task = this.keepAliveTask;
331+
if (task != null) {
332+
task.dispose();
333+
}
334+
Scheduler scheduler = this.keepAliveScheduler;
335+
if (scheduler != null) {
336+
scheduler.dispose();
337+
}
338+
}
339+
292340
private void stopServer() {
341+
stopKeepAlive();
293342
Server currentServer = this.server;
294343
if (currentServer != null) {
295344
try {
@@ -339,7 +388,20 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
339388
return;
340389
}
341390

342-
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
391+
long declaredLength = request.getContentLengthLong();
392+
if (declaredLength > options.maxPostBodyBytes()) {
393+
writeText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE,
394+
"POST body exceeds " + options.maxPostBodyBytes() + " bytes");
395+
return;
396+
}
397+
byte[] bodyBytes = request.getInputStream().readNBytes((int) Math.min(Integer.MAX_VALUE,
398+
options.maxPostBodyBytes() + 1));
399+
if (bodyBytes.length > options.maxPostBodyBytes()) {
400+
writeText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE,
401+
"POST body exceeds " + options.maxPostBodyBytes() + " bytes");
402+
return;
403+
}
404+
String body = new String(bodyBytes, StandardCharsets.UTF_8);
343405
if (body.stripLeading().startsWith("[")) {
344406
writeText(response, HttpServletResponse.SC_NOT_IMPLEMENTED, "JSON-RPC batches are not supported");
345407
return;
@@ -693,8 +755,13 @@ private RouteScope resolveAgentOutboundScope(JSONRPCMessage message) {
693755
String sessionId = extractSessionIdFromNewSessionResponse(response);
694756
markSessionKnown(sessionId);
695757
}
696-
if (route.kind() == RequestKind.SESSION_LOAD && response.error() == null) {
697-
markSessionKnown(route.requestScope().sessionId());
758+
if (route.kind() == RequestKind.SESSION_LOAD) {
759+
if (response.error() == null) {
760+
markSessionKnown(route.requestScope().sessionId());
761+
}
762+
else {
763+
discardProvisionalSession(route.requestScope().sessionId());
764+
}
698765
}
699766
return route.responseScope();
700767
}
@@ -816,7 +883,7 @@ private void prepareSessionForInbound(String sessionId, ClientRequestRoute route
816883
SessionState current = sessions.get(sessionId);
817884
if (route != null && route.kind() == RequestKind.SESSION_LOAD) {
818885
if (current == null) {
819-
sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD);
886+
addProvisionalSession(sessionId);
820887
sessionStream(sessionId);
821888
}
822889
return;
@@ -849,11 +916,37 @@ private OutboundStream openSessionStream(String sessionId) {
849916
* but its resume flow also asks clients to open a session stream before
850917
* sending session/load. Keep a provisional stream so practical resume can work.
851918
*/
852-
sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD);
919+
addProvisionalSession(sessionId);
853920
}
854921
return sessionStream(sessionId);
855922
}
856923

924+
/** Provisional sessions are bounded: a client cannot grow state with arbitrary ids. */
925+
private void addProvisionalSession(String sessionId) {
926+
long provisional = sessions.values().stream().filter(state -> state == SessionState.PENDING_LOAD).count();
927+
if (provisional >= options.maxProvisionalSessions()
928+
&& sessions.get(sessionId) != SessionState.PENDING_LOAD) {
929+
throw new UnknownSessionException("Too many provisional sessions on connection " + id
930+
+ " (limit " + options.maxProvisionalSessions() + ")");
931+
}
932+
sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD);
933+
}
934+
935+
/** A failed session/load leaves no provisional state behind. */
936+
private void discardProvisionalSession(String sessionId) {
937+
if (sessions.remove(sessionId, SessionState.PENDING_LOAD)) {
938+
OutboundStream stream = sessionStreams.remove(sessionId);
939+
if (stream != null) {
940+
stream.close();
941+
}
942+
}
943+
}
944+
945+
void keepAlive() {
946+
connectionStream.keepAlive();
947+
sessionStreams.values().forEach(OutboundStream::keepAlive);
948+
}
949+
857950
private OutboundStream sessionStream(String sessionId) {
858951
return sessionStreams.computeIfAbsent(sessionId, ignored -> new OutboundStream());
859952
}
@@ -909,9 +1002,9 @@ synchronized void push(String payload) {
9091002
return;
9101003
}
9111004
if (subscribers.isEmpty()) {
912-
if (replay.size() == MAX_REPLAY_EVENTS) {
1005+
if (replay.size() == options.mailboxCapacity()) {
9131006
throw new AcpConnectionException(
914-
"Outbound SSE replay buffer exceeded " + MAX_REPLAY_EVENTS + " events");
1007+
"Outbound SSE replay buffer exceeded " + options.mailboxCapacity() + " events");
9151008
}
9161009
replay.addLast(payload);
9171010
return;
@@ -926,6 +1019,14 @@ synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse respo
9261019
asyncContext.complete();
9271020
return;
9281021
}
1022+
// One subscriber per stream: a new GET takes the stream over from a previous
1023+
// one that the server may not yet know is dead (proxy drop, client restart).
1024+
// Rust and TypeScript answer 409 instead; taking over is friendlier to a
1025+
// reconnecting client and never duplicates events.
1026+
for (SseSubscriber previous : new ArrayList<>(subscribers)) {
1027+
logger.debug("New SSE subscriber replaces the attached one");
1028+
previous.close();
1029+
}
9291030
SseSubscriber subscriber = new SseSubscriber(this, asyncContext, response);
9301031
subscribers.add(subscriber);
9311032
subscriber.start();
@@ -940,6 +1041,12 @@ void remove(SseSubscriber subscriber) {
9401041
subscribers.remove(subscriber);
9411042
}
9421043

1044+
void keepAlive() {
1045+
if (!closed.get()) {
1046+
subscribers.forEach(SseSubscriber::sendKeepAlive);
1047+
}
1048+
}
1049+
9431050
synchronized void close() {
9441051
if (closed.compareAndSet(false, true)) {
9451052
subscribers.forEach(SseSubscriber::close);
@@ -980,15 +1087,24 @@ synchronized void send(String payload) {
9801087
if (closed.get()) {
9811088
return;
9821089
}
983-
if (pendingEvents.size() == MAX_PENDING_SSE_EVENTS) {
984-
logger.warn("Closing backpressured SSE subscriber after {} pending events", MAX_PENDING_SSE_EVENTS);
1090+
if (pendingEvents.size() == options.maxPendingSseEvents()) {
1091+
logger.warn("Closing backpressured SSE subscriber after {} pending events",
1092+
options.maxPendingSseEvents());
9851093
close();
9861094
return;
9871095
}
9881096
pendingEvents.addLast(("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8));
9891097
drain();
9901098
}
9911099

1100+
synchronized void sendKeepAlive() {
1101+
if (closed.get() || !pendingEvents.isEmpty()) {
1102+
return;
1103+
}
1104+
pendingEvents.addLast(SSE_KEEP_ALIVE_COMMENT);
1105+
drain();
1106+
}
1107+
9921108
synchronized void drain() {
9931109
try {
9941110
flushIfReady();
@@ -1165,6 +1281,10 @@ void send(String payload) {
11651281
if (closed.get()) {
11661282
throw new AcpConnectionException("Streamable ACP WebSocket connection is closed");
11671283
}
1284+
if (queue.size() >= options.maxWebSocketPendingFrames()) {
1285+
throw new AcpConnectionException("WebSocket send queue exceeded "
1286+
+ options.maxWebSocketPendingFrames() + " pending frames");
1287+
}
11681288
queue.addLast(payload);
11691289
shouldDrain = !sendInProgress;
11701290
if (shouldDrain) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*/
4+
5+
package com.agentclientprotocol.sdk.agent.transport;
6+
7+
import java.time.Duration;
8+
9+
import com.agentclientprotocol.sdk.util.Assert;
10+
11+
/**
12+
* Limits and timings for {@link StreamableHttpAcpAgentTransport}. Every network-facing
13+
* allocation is bounded by one of these so a slow or hostile client cannot grow memory
14+
* without bound.
15+
*
16+
* @param maxPostBodyBytes largest accepted POST body; larger requests get 413
17+
* @param mailboxCapacity events retained per outbound stream while no subscriber is
18+
* attached; overflow closes the connection
19+
* @param maxPendingSseEvents events queued for one attached SSE subscriber before it is
20+
* closed as backpressured
21+
* @param maxWebSocketPendingFrames frames queued for one WebSocket connection before it is
22+
* closed as backpressured
23+
* @param maxProvisionalSessions session-scoped streams a connection may open before the
24+
* session is known (the {@code session/load} pre-open case)
25+
* @param keepAliveInterval interval between SSE keep-alive comments on attached streams;
26+
* {@link Duration#ZERO} disables them
27+
* @author Mark Pollack
28+
*/
29+
public record StreamableHttpAcpAgentTransportOptions(long maxPostBodyBytes, int mailboxCapacity,
30+
int maxPendingSseEvents, int maxWebSocketPendingFrames, int maxProvisionalSessions,
31+
Duration keepAliveInterval) {
32+
33+
private static final long DEFAULT_MAX_POST_BODY_BYTES = 16L * 1024 * 1024;
34+
35+
private static final int DEFAULT_MAILBOX_CAPACITY = 1024;
36+
37+
private static final int DEFAULT_MAX_PENDING_SSE_EVENTS = 1024;
38+
39+
private static final int DEFAULT_MAX_WEBSOCKET_PENDING_FRAMES = 1024;
40+
41+
private static final int DEFAULT_MAX_PROVISIONAL_SESSIONS = 64;
42+
43+
private static final Duration DEFAULT_KEEP_ALIVE_INTERVAL = Duration.ofSeconds(15);
44+
45+
public StreamableHttpAcpAgentTransportOptions {
46+
Assert.isTrue(maxPostBodyBytes > 0, "maxPostBodyBytes must be positive");
47+
Assert.isTrue(mailboxCapacity > 0, "mailboxCapacity must be positive");
48+
Assert.isTrue(maxPendingSseEvents > 0, "maxPendingSseEvents must be positive");
49+
Assert.isTrue(maxWebSocketPendingFrames > 0, "maxWebSocketPendingFrames must be positive");
50+
Assert.isTrue(maxProvisionalSessions > 0, "maxProvisionalSessions must be positive");
51+
Assert.notNull(keepAliveInterval, "keepAliveInterval must not be null");
52+
Assert.isTrue(!keepAliveInterval.isNegative(), "keepAliveInterval must not be negative");
53+
}
54+
55+
public static StreamableHttpAcpAgentTransportOptions defaults() {
56+
return builder().build();
57+
}
58+
59+
public static Builder builder() {
60+
return new Builder();
61+
}
62+
63+
public static final class Builder {
64+
65+
private long maxPostBodyBytes = DEFAULT_MAX_POST_BODY_BYTES;
66+
67+
private int mailboxCapacity = DEFAULT_MAILBOX_CAPACITY;
68+
69+
private int maxPendingSseEvents = DEFAULT_MAX_PENDING_SSE_EVENTS;
70+
71+
private int maxWebSocketPendingFrames = DEFAULT_MAX_WEBSOCKET_PENDING_FRAMES;
72+
73+
private int maxProvisionalSessions = DEFAULT_MAX_PROVISIONAL_SESSIONS;
74+
75+
private Duration keepAliveInterval = DEFAULT_KEEP_ALIVE_INTERVAL;
76+
77+
private Builder() {
78+
}
79+
80+
public Builder maxPostBodyBytes(long maxPostBodyBytes) {
81+
this.maxPostBodyBytes = maxPostBodyBytes;
82+
return this;
83+
}
84+
85+
public Builder mailboxCapacity(int mailboxCapacity) {
86+
this.mailboxCapacity = mailboxCapacity;
87+
return this;
88+
}
89+
90+
public Builder maxPendingSseEvents(int maxPendingSseEvents) {
91+
this.maxPendingSseEvents = maxPendingSseEvents;
92+
return this;
93+
}
94+
95+
public Builder maxWebSocketPendingFrames(int maxWebSocketPendingFrames) {
96+
this.maxWebSocketPendingFrames = maxWebSocketPendingFrames;
97+
return this;
98+
}
99+
100+
public Builder maxProvisionalSessions(int maxProvisionalSessions) {
101+
this.maxProvisionalSessions = maxProvisionalSessions;
102+
return this;
103+
}
104+
105+
public Builder keepAliveInterval(Duration keepAliveInterval) {
106+
this.keepAliveInterval = keepAliveInterval;
107+
return this;
108+
}
109+
110+
public StreamableHttpAcpAgentTransportOptions build() {
111+
return new StreamableHttpAcpAgentTransportOptions(maxPostBodyBytes, mailboxCapacity, maxPendingSseEvents,
112+
maxWebSocketPendingFrames, maxProvisionalSessions, keepAliveInterval);
113+
}
114+
115+
}
116+
117+
}

0 commit comments

Comments
 (0)