@@ -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 }
0 commit comments