5656import org .eclipse .jetty .websocket .server .WebSocketUpgradeHandler ;
5757import org .slf4j .Logger ;
5858import 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 ;
5963import reactor .core .publisher .Mono ;
6064import 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 ) {
0 commit comments