Skip to content

Commit aa44e3f

Browse files
author
Mark Pollack
committed
Lock the SSE outbound stream on a private object
SseOutboundStream guarded its mailbox and subscriber with its own monitor (synchronized methods, and synchronized (SseOutboundStream.this) in the subscriber's drain). Any code holding the stream could take that monitor and stall the container's write callbacks. A private final lock keeps the same serialisation without exposing it. SpotBugs reports the old shape as USO_UNSAFE_OBJECT_SYNCHRONIZATION (rank 7).
1 parent 8be8b9d commit aa44e3f

1 file changed

Lines changed: 87 additions & 71 deletions

File tree

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

Lines changed: 87 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
* backpressure, a new GET took the stream over, or the container reported an error on
3333
* another thread) its unconfirmed events go back to the front of the queue in order, and
3434
* the next subscriber continues from there. All state changes happen under this stream's
35-
* monitor, including the container's asynchronous callbacks, so a reconnect racing the old
36-
* subscriber's close is serialised.
35+
* private lock, including the container's asynchronous callbacks, so a reconnect racing
36+
* the old subscriber's close is serialised.
3737
* </p>
3838
*
3939
* <p>
@@ -62,17 +62,23 @@ final class SseOutboundStream {
6262

6363
private static final byte[] NO_BYTES = new byte[0];
6464

65+
/**
66+
* Guards all state of this stream and its subscriber. Private, not this stream's own
67+
* monitor, so no other code can take it and stall the container's write callbacks.
68+
*/
69+
private final Object lock = new Object();
70+
6571
private final int mailboxCapacity;
6672

6773
private final int maxPendingSseEvents;
6874

69-
/** Events not yet written to any subscriber, oldest first. Guarded by {@code this}. */
75+
/** Events not yet written to any subscriber, oldest first. Guarded by {@code lock}. */
7076
private final ArrayDeque<String> mailbox = new ArrayDeque<>();
7177

72-
/** Guarded by {@code this}. */
78+
/** Guarded by {@code lock}. */
7379
private @Nullable SseSubscriber current;
7480

75-
/** Guarded by {@code this}. */
81+
/** Guarded by {@code lock}. */
7682
private boolean closed;
7783

7884
SseOutboundStream(int mailboxCapacity, int maxPendingSseEvents) {
@@ -85,90 +91,100 @@ final class SseOutboundStream {
8591
* @throws AcpConnectionException when the mailbox is full; the caller closes the
8692
* connection rather than drop an event
8793
*/
88-
synchronized void push(String payload) {
89-
if (closed) {
90-
return;
91-
}
92-
if (mailbox.size() + (current != null ? current.unconfirmed.size() : 0) >= mailboxCapacity) {
93-
throw new AcpConnectionException("Outbound SSE replay buffer exceeded " + mailboxCapacity + " events");
94-
}
95-
mailbox.addLast(payload);
96-
if (current == null) {
97-
return;
98-
}
99-
if (mailbox.size() > maxPendingSseEvents) {
100-
// The attached client is not reading. Detach it; the events stay queued for the
101-
// next GET, and a full mailbox then closes the connection.
102-
logger.warn("Closing backpressured SSE subscriber after {} pending events", maxPendingSseEvents);
103-
detach(current);
104-
return;
94+
void push(String payload) {
95+
synchronized (lock) {
96+
if (closed) {
97+
return;
98+
}
99+
if (mailbox.size() + (current != null ? current.unconfirmed.size() : 0) >= mailboxCapacity) {
100+
throw new AcpConnectionException("Outbound SSE replay buffer exceeded " + mailboxCapacity + " events");
101+
}
102+
mailbox.addLast(payload);
103+
if (current == null) {
104+
return;
105+
}
106+
if (mailbox.size() > maxPendingSseEvents) {
107+
// The attached client is not reading. Detach it; the events stay queued for the
108+
// next GET, and a full mailbox then closes the connection.
109+
logger.warn("Closing backpressured SSE subscriber after {} pending events", maxPendingSseEvents);
110+
detach(current);
111+
return;
112+
}
113+
current.drain();
105114
}
106-
current.drain();
107115
}
108116

109-
synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse response) throws IOException {
110-
if (closed) {
111-
// DELETE may close the connection after GET has started async processing.
112-
// Complete that request instead of leaving its response open indefinitely.
113-
asyncContext.complete();
114-
return;
115-
}
116-
// One subscriber per stream: a new GET takes the stream over from a previous
117-
// one that the server may not yet know is dead (proxy drop, client restart).
118-
// Rust and TypeScript answer 409 instead; taking over is friendlier to a
119-
// reconnecting client and never duplicates events.
120-
if (current != null) {
121-
logger.debug("New SSE subscriber replaces the attached one");
122-
detach(current);
117+
void subscribe(AsyncContext asyncContext, HttpServletResponse response) throws IOException {
118+
synchronized (lock) {
119+
if (closed) {
120+
// DELETE may close the connection after GET has started async processing.
121+
// Complete that request instead of leaving its response open indefinitely.
122+
asyncContext.complete();
123+
return;
124+
}
125+
// One subscriber per stream: a new GET takes the stream over from a previous
126+
// one that the server may not yet know is dead (proxy drop, client restart).
127+
// Rust and TypeScript answer 409 instead; taking over is friendlier to a
128+
// reconnecting client and never duplicates events.
129+
if (current != null) {
130+
logger.debug("New SSE subscriber replaces the attached one");
131+
detach(current);
132+
}
133+
SseSubscriber subscriber = new SseSubscriber(asyncContext, response);
134+
current = subscriber;
135+
subscriber.start();
136+
subscriber.drain();
123137
}
124-
SseSubscriber subscriber = new SseSubscriber(asyncContext, response);
125-
current = subscriber;
126-
subscriber.start();
127-
subscriber.drain();
128138
}
129139

130-
synchronized void keepAlive() {
131-
if (!closed && current != null) {
132-
current.sendKeepAlive();
140+
void keepAlive() {
141+
synchronized (lock) {
142+
if (!closed && current != null) {
143+
current.sendKeepAlive();
144+
}
133145
}
134146
}
135147

136-
synchronized void close() {
137-
if (closed) {
138-
return;
139-
}
140-
closed = true;
141-
if (current != null) {
142-
detach(current);
148+
void close() {
149+
synchronized (lock) {
150+
if (closed) {
151+
return;
152+
}
153+
closed = true;
154+
if (current != null) {
155+
detach(current);
156+
}
157+
mailbox.clear();
143158
}
144-
mailbox.clear();
145159
}
146160

147161
/** Detaches a subscriber if it is still the attached one, and completes its response. */
148-
private synchronized void detach(SseSubscriber subscriber) {
149-
if (subscriber.detached) {
150-
return;
151-
}
152-
subscriber.detached = true;
153-
if (current == subscriber) {
154-
current = null;
155-
}
156-
// Not known to have reached the client: back to the front, in order.
157-
while (!subscriber.unconfirmed.isEmpty()) {
158-
mailbox.addFirst(subscriber.unconfirmed.removeLast());
159-
}
160-
try {
161-
subscriber.asyncContext.complete();
162-
}
163-
catch (IllegalStateException ignored) {
164-
// already completed by the container
162+
private void detach(SseSubscriber subscriber) {
163+
synchronized (lock) {
164+
if (subscriber.detached) {
165+
return;
166+
}
167+
subscriber.detached = true;
168+
if (current == subscriber) {
169+
current = null;
170+
}
171+
// Not known to have reached the client: back to the front, in order.
172+
while (!subscriber.unconfirmed.isEmpty()) {
173+
mailbox.addFirst(subscriber.unconfirmed.removeLast());
174+
}
175+
try {
176+
subscriber.asyncContext.complete();
177+
}
178+
catch (IllegalStateException ignored) {
179+
// already completed by the container
180+
}
165181
}
166182
}
167183

168184
/**
169185
* Writes to one servlet async response. Holds no events of its own except comments and
170186
* the events it wrote whose write has not yet completed; every method runs under the
171-
* enclosing stream's monitor.
187+
* enclosing stream's lock.
172188
*/
173189
private final class SseSubscriber implements AsyncListener, WriteListener {
174190

@@ -208,7 +224,7 @@ void sendKeepAlive() {
208224
}
209225

210226
void drain() {
211-
synchronized (SseOutboundStream.this) {
227+
synchronized (lock) {
212228
if (detached) {
213229
return;
214230
}

0 commit comments

Comments
 (0)