@@ -935,6 +935,12 @@ private final class WebSocketConnectionState {
935935
936936 private final AtomicBoolean closed = new AtomicBoolean (false );
937937
938+ private final Object outboundLock = new Object ();
939+
940+ private final ArrayDeque <String > outboundQueue = new ArrayDeque <>();
941+
942+ private boolean outboundSendInProgress = false ;
943+
938944 private volatile Session session ;
939945
940946 WebSocketConnectionState (String id ) {
@@ -970,27 +976,76 @@ void acceptFromClient(JSONRPCMessage message) {
970976
971977 void sendToClient (JSONRPCMessage message ) {
972978 try {
973- Session currentSession = this .session ;
974- if (closed .get () || currentSession == null || !currentSession .isOpen ()) {
975- throw new AcpConnectionException ("Streamable ACP WebSocket connection is closed" );
976- }
977979 String payload = jsonMapper .writeValueAsString (message );
978980 logger .debug ("Sending streamable ACP WebSocket message: {}" , payload );
979- currentSession .sendText (payload , Callback .from (() -> {
980- // Jetty requires an explicit success callback; there is no
981- // follow-up work after the frame has been accepted for writing.
982- }, error -> {
983- if (!closed .get ()) {
984- remoteConnection .signalException (error );
985- }
986- }));
981+ enqueueOutbound (payload );
987982 }
988983 catch (Exception e ) {
989984 remoteConnection .signalException (e );
990985 close (StatusCode .SERVER_ERROR , "failed to send ACP message" );
991986 }
992987 }
993988
989+ private void enqueueOutbound (String payload ) {
990+ boolean shouldDrain ;
991+ synchronized (outboundLock ) {
992+ if (closed .get ()) {
993+ throw new AcpConnectionException ("Streamable ACP WebSocket connection is closed" );
994+ }
995+ outboundQueue .addLast (payload );
996+ shouldDrain = !outboundSendInProgress ;
997+ if (shouldDrain ) {
998+ outboundSendInProgress = true ;
999+ }
1000+ }
1001+ if (shouldDrain ) {
1002+ drainOutbound ();
1003+ }
1004+ }
1005+
1006+ private void drainOutbound () {
1007+ String payload ;
1008+ Session currentSession ;
1009+ synchronized (outboundLock ) {
1010+ if (closed .get ()) {
1011+ outboundQueue .clear ();
1012+ outboundSendInProgress = false ;
1013+ return ;
1014+ }
1015+ payload = outboundQueue .pollFirst ();
1016+ if (payload == null ) {
1017+ outboundSendInProgress = false ;
1018+ return ;
1019+ }
1020+ currentSession = this .session ;
1021+ }
1022+
1023+ if (currentSession == null || !currentSession .isOpen ()) {
1024+ failOutbound (new AcpConnectionException ("Streamable ACP WebSocket connection is closed" ));
1025+ return ;
1026+ }
1027+
1028+ try {
1029+ /*
1030+ * Jetty WebSocket sessions do not allow overlapping writes. Agent messages can
1031+ * be produced by concurrent prompt handlers, so this per-connection queue sends
1032+ * exactly one frame at a time and advances only after Jetty completes the
1033+ * callback for the previous frame.
1034+ */
1035+ currentSession .sendText (payload , Callback .from (this ::drainOutbound , this ::failOutbound ));
1036+ }
1037+ catch (Exception e ) {
1038+ failOutbound (e );
1039+ }
1040+ }
1041+
1042+ private void failOutbound (Throwable error ) {
1043+ if (!closed .get ()) {
1044+ remoteConnection .signalException (error );
1045+ close (StatusCode .SERVER_ERROR , "failed to send ACP message" );
1046+ }
1047+ }
1048+
9941049 void close () {
9951050 close (StatusCode .NORMAL , "server closing" );
9961051 }
@@ -999,6 +1054,10 @@ void close(int statusCode, String reason) {
9991054 if (!closed .compareAndSet (false , true )) {
10001055 return ;
10011056 }
1057+ synchronized (outboundLock ) {
1058+ outboundQueue .clear ();
1059+ outboundSendInProgress = false ;
1060+ }
10021061 webSocketConnections .remove (id , this );
10031062 Session currentSession = this .session ;
10041063 if (currentSession != null && currentSession .isOpen ()) {
0 commit comments