Skip to content

Commit b2d39f7

Browse files
committed
fix: avoid blocking streamable SSE writes
1 parent 0e4e3fc commit b2d39f7

2 files changed

Lines changed: 113 additions & 16 deletions

File tree

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

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
package com.agentclientprotocol.sdk.agent.transport;
66

77
import java.io.IOException;
8-
import java.io.PrintWriter;
98
import java.nio.channels.ClosedChannelException;
109
import java.nio.charset.StandardCharsets;
1110
import java.time.Duration;
@@ -30,7 +29,11 @@
3029
import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
3130
import com.agentclientprotocol.sdk.util.Assert;
3231
import jakarta.servlet.AsyncContext;
32+
import jakarta.servlet.AsyncEvent;
33+
import jakarta.servlet.AsyncListener;
3334
import jakarta.servlet.ServletException;
35+
import jakarta.servlet.ServletOutputStream;
36+
import jakarta.servlet.WriteListener;
3437
import jakarta.servlet.http.HttpServlet;
3538
import jakarta.servlet.http.HttpServletRequest;
3639
import jakarta.servlet.http.HttpServletResponse;
@@ -92,6 +95,8 @@ public class StreamableHttpAcpAgentTransport {
9295

9396
private static final int MAX_REPLAY_EVENTS = 1024;
9497

98+
private static final int MAX_PENDING_SSE_EVENTS = 1024;
99+
95100
private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30);
96101

97102
private enum ScopeKind {
@@ -827,68 +832,118 @@ synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse respo
827832
}
828833
SseSubscriber subscriber = new SseSubscriber(this, asyncContext, response);
829834
subscribers.add(subscriber);
835+
subscriber.start();
830836
if (replayOpen) {
831837
for (String payload : new ArrayList<>(replay)) {
832838
subscriber.send(payload);
833839
}
834840
replay.clear();
835841
replayOpen = false;
836842
}
837-
subscriber.flush();
843+
subscriber.drain();
838844
}
839845

840846
void remove(SseSubscriber subscriber) {
841847
subscribers.remove(subscriber);
842848
}
843849

844-
void close() {
850+
synchronized void close() {
845851
if (closed.compareAndSet(false, true)) {
846852
subscribers.forEach(SseSubscriber::close);
847853
subscribers.clear();
848-
synchronized (this) {
849-
replay.clear();
850-
}
854+
replay.clear();
851855
}
852856
}
853857

854858
}
855859

856-
private final class SseSubscriber {
860+
private final class SseSubscriber implements AsyncListener, WriteListener {
857861

858862
private final OutboundStream parent;
859863

860864
private final AsyncContext asyncContext;
861865

862-
private final PrintWriter writer;
866+
private final ServletOutputStream output;
867+
868+
private final ArrayDeque<byte[]> pendingEvents = new ArrayDeque<>();
863869

864870
private final AtomicBoolean closed = new AtomicBoolean(false);
865871

866872
SseSubscriber(OutboundStream parent, AsyncContext asyncContext, HttpServletResponse response) throws IOException {
867873
this.parent = parent;
868874
this.asyncContext = asyncContext;
869-
this.writer = response.getWriter();
875+
this.output = response.getOutputStream();
876+
}
877+
878+
void start() {
879+
asyncContext.addListener(this);
880+
output.setWriteListener(this);
870881
}
871882

872883
synchronized void send(String payload) {
873884
if (closed.get()) {
874885
return;
875886
}
876-
writer.write("data: ");
877-
writer.write(payload);
878-
writer.write("\n\n");
879-
writer.flush();
880-
if (writer.checkError()) {
887+
if (pendingEvents.size() == MAX_PENDING_SSE_EVENTS) {
888+
logger.warn("Closing backpressured SSE subscriber after {} pending events", MAX_PENDING_SSE_EVENTS);
881889
close();
890+
return;
882891
}
892+
pendingEvents.addLast(("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8));
893+
drain();
883894
}
884895

885-
synchronized void flush() {
886-
writer.flush();
896+
synchronized void drain() {
897+
try {
898+
while (!closed.get() && output.isReady()) {
899+
byte[] event = pendingEvents.pollFirst();
900+
if (event == null) {
901+
return;
902+
}
903+
output.write(event);
904+
}
905+
}
906+
catch (IOException e) {
907+
close();
908+
}
909+
}
910+
911+
@Override
912+
public void onWritePossible() {
913+
drain();
914+
}
915+
916+
@Override
917+
public void onError(Throwable error) {
918+
close();
919+
}
920+
921+
@Override
922+
public void onComplete(AsyncEvent event) {
923+
close();
924+
}
925+
926+
@Override
927+
public void onTimeout(AsyncEvent event) {
928+
close();
929+
}
930+
931+
@Override
932+
public void onError(AsyncEvent event) {
933+
close();
934+
}
935+
936+
@Override
937+
public void onStartAsync(AsyncEvent event) {
938+
event.getAsyncContext().addListener(this);
887939
}
888940

889941
void close() {
890942
if (closed.compareAndSet(false, true)) {
891943
parent.remove(this);
944+
synchronized (this) {
945+
pendingEvents.clear();
946+
}
892947
try {
893948
asyncContext.complete();
894949
}

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,48 @@ void deleteClosesSseAndRemovesConnection() throws Exception {
383383
}
384384
}
385385

386+
@Test
387+
void concurrentSseOpenAndDeleteDoesNotLeaveStreamOpen() throws Exception {
388+
try (FixtureServer server = FixtureServer.start()) {
389+
HttpClient rawClient = HttpClient.newHttpClient();
390+
String connectionId = initializeRaw(rawClient, server.endpoint());
391+
ExecutorService executor = Executors.newFixedThreadPool(2);
392+
CountDownLatch start = new CountDownLatch(1);
393+
try {
394+
Future<HttpResponse<InputStream>> openStream = executor.submit(() -> {
395+
start.await();
396+
return rawClient.send(HttpRequest.newBuilder(server.endpoint())
397+
.header("Accept", "text/event-stream")
398+
.header("Acp-Connection-Id", connectionId)
399+
.GET()
400+
.build(), HttpResponse.BodyHandlers.ofInputStream());
401+
});
402+
Future<HttpResponse<Void>> delete = executor.submit(() -> {
403+
start.await();
404+
return rawClient.send(HttpRequest.newBuilder(server.endpoint())
405+
.header("Acp-Connection-Id", connectionId)
406+
.DELETE()
407+
.build(), HttpResponse.BodyHandlers.discarding());
408+
});
409+
410+
start.countDown();
411+
HttpResponse<InputStream> streamResponse = openStream.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
412+
assertThat(delete.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).statusCode()).isEqualTo(202);
413+
assertThat(streamResponse.statusCode()).isIn(200, 404);
414+
if (streamResponse.statusCode() == 200) {
415+
try (InputStream body = streamResponse.body()) {
416+
Future<Integer> endOfStream = executor.submit((java.util.concurrent.Callable<Integer>) body::read);
417+
assertThat(endOfStream.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isEqualTo(-1);
418+
}
419+
}
420+
assertEventuallyPostStatus(rawClient, server.endpoint(), connectionId, 404);
421+
}
422+
finally {
423+
executor.shutdownNow();
424+
}
425+
}
426+
}
427+
386428
@Test
387429
void replayOverflowClosesConnectionInsteadOfDroppingMessages() throws Exception {
388430
try (FixtureServer server = FixtureServer.start()) {

0 commit comments

Comments
 (0)