Skip to content

Commit 8322751

Browse files
author
Mark Pollack
committed
Return a closed SSE subscriber's undelivered events to the mailbox
When the server closed a subscriber (backpressure, a take-over GET, or a write failure), the events still queued for it, and the one that tipped the queue over, were dropped with it: an accepted prompt's result could be lost although the stream is a mailbox. They now go back to the front of the mailbox in order and reach the next subscriber. Bytes already written to a connection that then died can still be lost; only event ids and Last-Event-ID, which the RFD defers, could close that.
1 parent 184cc2e commit 8322751

2 files changed

Lines changed: 64 additions & 16 deletions

File tree

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

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,42 @@ synchronized void push(String payload) {
6565
return;
6666
}
6767
if (subscribers.isEmpty()) {
68-
if (replay.size() == mailboxCapacity) {
69-
throw new AcpConnectionException(
70-
"Outbound SSE replay buffer exceeded " + mailboxCapacity + " events");
68+
synchronized (replay) {
69+
if (replay.size() == mailboxCapacity) {
70+
throw new AcpConnectionException(
71+
"Outbound SSE replay buffer exceeded " + mailboxCapacity + " events");
72+
}
73+
replay.addLast(payload);
7174
}
72-
replay.addLast(payload);
7375
return;
7476
}
7577
subscribers.forEach(subscriber -> subscriber.send(payload));
7678
}
7779

80+
/**
81+
* Puts events a closed subscriber never wrote back at the front of the mailbox, in
82+
* order, so the next subscriber receives them. Takes only the mailbox lock, so a
83+
* subscriber may call it while holding its own lock; a push that lands in the
84+
* mailbox meanwhile is newer and correctly stays behind them. Bytes already written
85+
* to a connection that then died can still be lost: without event ids and
86+
* {@code Last-Event-ID} (deferred by the RFD) nothing can know they did not arrive.
87+
*/
88+
private void requeue(List<String> unsent) {
89+
if (unsent.isEmpty() || closed.get()) {
90+
return;
91+
}
92+
synchronized (replay) {
93+
if (replay.size() + unsent.size() > mailboxCapacity) {
94+
logger.warn("Dropping {} undelivered SSE events: mailbox full ({} events)", unsent.size(),
95+
mailboxCapacity);
96+
return;
97+
}
98+
for (int i = unsent.size() - 1; i >= 0; i--) {
99+
replay.addFirst(unsent.get(i));
100+
}
101+
}
102+
}
103+
78104
synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse response) throws IOException {
79105
if (closed.get()) {
80106
// DELETE may close the connection after GET has started async processing.
@@ -93,10 +119,14 @@ synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse respo
93119
SseSubscriber subscriber = new SseSubscriber(this, asyncContext, response);
94120
subscribers.add(subscriber);
95121
subscriber.start();
96-
for (String payload : new ArrayList<>(replay)) {
122+
List<String> retained;
123+
synchronized (replay) {
124+
retained = new ArrayList<>(replay);
125+
replay.clear();
126+
}
127+
for (String payload : retained) {
97128
subscriber.send(payload);
98129
}
99-
replay.clear();
100130
subscriber.drain();
101131
}
102132

@@ -114,7 +144,9 @@ synchronized void close() {
114144
if (closed.compareAndSet(false, true)) {
115145
subscribers.forEach(SseSubscriber::close);
116146
subscribers.clear();
117-
replay.clear();
147+
synchronized (replay) {
148+
replay.clear();
149+
}
118150
}
119151
}
120152

@@ -127,7 +159,11 @@ private static final class SseSubscriber implements AsyncListener, WriteListener
127159

128160
private final ServletOutputStream output;
129161

130-
private final ArrayDeque<byte[]> pendingEvents = new ArrayDeque<>();
162+
/** Queued writes; {@code payload} is null for comments (open, keep-alive), which are not requeued. */
163+
private record Pending(byte[] bytes, String payload) {
164+
}
165+
166+
private final ArrayDeque<Pending> pendingEvents = new ArrayDeque<>();
131167

132168
private final AtomicBoolean closed = new AtomicBoolean(false);
133169

@@ -141,7 +177,7 @@ private static final class SseSubscriber implements AsyncListener, WriteListener
141177

142178
synchronized void start() {
143179
asyncContext.addListener(this);
144-
pendingEvents.addLast(SSE_OPEN_COMMENT);
180+
pendingEvents.addLast(new Pending(SSE_OPEN_COMMENT, null));
145181
output.setWriteListener(this);
146182
}
147183

@@ -152,30 +188,32 @@ synchronized void send(String payload) {
152188
if (pendingEvents.size() == parent.maxPendingSseEvents) {
153189
logger.warn("Closing backpressured SSE subscriber after {} pending events",
154190
parent.maxPendingSseEvents);
191+
// The event that did not fit goes back to the mailbox with the queue.
192+
pendingEvents.addLast(new Pending(null, payload));
155193
close();
156194
return;
157195
}
158-
pendingEvents.addLast(("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8));
196+
pendingEvents.addLast(new Pending(("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8), payload));
159197
drain();
160198
}
161199

162200
synchronized void sendKeepAlive() {
163201
if (closed.get() || !pendingEvents.isEmpty()) {
164202
return;
165203
}
166-
pendingEvents.addLast(SSE_KEEP_ALIVE_COMMENT);
204+
pendingEvents.addLast(new Pending(SSE_KEEP_ALIVE_COMMENT, null));
167205
drain();
168206
}
169207

170208
synchronized void drain() {
171209
try {
172210
flushIfReady();
173211
while (!closed.get() && output.isReady()) {
174-
byte[] event = pendingEvents.pollFirst();
212+
Pending event = pendingEvents.pollFirst();
175213
if (event == null) {
176214
break;
177215
}
178-
output.write(event);
216+
output.write(event.bytes());
179217
flushPending = true;
180218
}
181219
flushIfReady();
@@ -225,9 +263,17 @@ public void onStartAsync(AsyncEvent event) {
225263
void close() {
226264
if (closed.compareAndSet(false, true)) {
227265
parent.remove(this);
266+
List<String> unsent = new ArrayList<>();
228267
synchronized (this) {
268+
for (Pending event : pendingEvents) {
269+
if (event.payload() != null) {
270+
unsent.add(event.payload());
271+
}
272+
}
229273
pendingEvents.clear();
230274
}
275+
// Undelivered events are not lost with the subscriber (mailbox guarantee).
276+
parent.requeue(unsent);
231277
try {
232278
asyncContext.complete();
233279
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ void mailboxOverflowThrows() {
6060
}
6161

6262
@Test
63-
void backpressuredSubscriberIsClosedAndLaterEventsGoToTheMailbox() throws IOException {
63+
void backpressuredSubscriberIsClosedAndItsUndeliveredEventsReachTheNextSubscriber() throws IOException {
6464
SseOutboundStream stream = new SseOutboundStream(4, 2);
6565

6666
// Never ready: the open comment and every event stay queued on the subscriber.
@@ -70,7 +70,7 @@ void backpressuredSubscriberIsClosedAndLaterEventsGoToTheMailbox() throws IOExce
7070
verify(slow.asyncContext, never()).complete();
7171

7272
// The queue now holds maxPendingSseEvents (open comment + one event): the next
73-
// event closes the subscriber and is discarded with its queue.
73+
// event closes the subscriber; its unsent events go back to the mailbox.
7474
stream.push("\"overflow\"");
7575
verify(slow.asyncContext).complete();
7676

@@ -79,7 +79,9 @@ void backpressuredSubscriberIsClosedAndLaterEventsGoToTheMailbox() throws IOExce
7979
assertThat(slow.output.written()).isEmpty();
8080

8181
Attached next = attach(stream, true);
82-
assertThat(next.output.written()).isEqualTo(OPEN + "data: \"later\"\n\n");
82+
assertThat(next.output.written())
83+
.as("nothing the slow subscriber never wrote is lost, and order is kept")
84+
.isEqualTo(OPEN + "data: \"queued\"\n\n" + "data: \"overflow\"\n\n" + "data: \"later\"\n\n");
8385
verify(next.asyncContext, never()).complete();
8486
}
8587

0 commit comments

Comments
 (0)