Skip to content

STOMP ERROR frame is lost when another write is in progress on the session #37328

Description

@dkrizan

Affects: 7.0.9, 7.1.0-M1

When an inbound frame fails, for example a ChannelInterceptor throws on SUBSCRIBE, the client should get an ERROR frame before the connection closes. If another thread is writing to the same session at that moment, the client gets only the close.

Cause

  1. StompSubProtocolHandler.sendErrorMessage calls session.sendMessage(...) and then session.close(CloseStatus.PROTOCOL_ERROR) in finally.
  2. If another thread holds the flush lock of ConcurrentWebSocketSessionDecorator, sendMessage only adds the frame to the buffer.
  3. close() sets closeInProgress, so the other thread drops the buffered ERROR frame.

Clients that decide what to do based on the ERROR message, for example stop reconnecting on Unauthenticated, see a plain disconnect instead. Possibly related to #17911.

How to reproduce

A small Spring Boot 4.1.1 project (code below) with a simple broker and an interceptor that rejects every SUBSCRIBE:

  • errorFrameIsDelivered passes.
  • errorFrameIsDeliveredWhileAnotherWriteIsInProgress fails every time. A test-only decorator keeps the write of CONNECTED in progress until the SUBSCRIBE is rejected, like a slow client would. The client gets ConnectionLostException and no ERROR frame.

Possible fix

Maybe close() could first write what is already in the buffer, at least for PROTOCOL_ERROR. Or sendErrorMessage could close only after the frame is really written.

build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.1'
    id 'io.spring.dependency-management' version '1.1.7'
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

test {
    useJUnitPlatform()
    testLogging { events 'passed', 'failed'; exceptionFormat 'full' }
}
src/main/java/repro/ReproApplication.java
package repro;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessagingException;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@SpringBootApplication
public class ReproApplication {

    @Configuration
    @EnableWebSocketMessageBroker
    static class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/ws");
        }

        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.enableSimpleBroker("/topic");
        }

        /** Rejects every SUBSCRIBE the documented way: by throwing from an inbound interceptor. */
        @Override
        public void configureClientInboundChannel(ChannelRegistration registration) {
            registration.interceptors(new ChannelInterceptor() {
                @Override
                public Message<?> preSend(Message<?> message, MessageChannel channel) {
                    StompHeaderAccessor accessor =
                            MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                    if (accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
                        throw new MessagingException("Unauthenticated");
                    }
                    return message;
                }
            });
        }
    }
}
src/test/java/repro/ErrorFrameDeliveryTest.java
package repro;

import java.lang.reflect.Type;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Import;
import org.springframework.messaging.converter.SimpleMessageConverter;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
import org.springframework.web.socket.handler.WebSocketHandlerDecorator;
import org.springframework.web.socket.handler.WebSocketSessionDecorator;
import org.springframework.web.socket.messaging.WebSocketStompClient;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(classes = ReproApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(ErrorFrameDeliveryTest.SlowOutboundWriteConfig.class)
class ErrorFrameDeliveryTest {

    /** When true, the server's write of CONNECTED is still in progress while SUBSCRIBE is rejected. */
    static volatile boolean slowOutboundWrite;

    @Value("${local.server.port}")
    int port;

    @Test
    void errorFrameIsDelivered() throws Exception {
        slowOutboundWrite = false;
        assertThat(subscribeAndAwaitErrorFrame()).isEqualTo("Unauthenticated");
    }

    @Test
    void errorFrameIsDeliveredWhileAnotherWriteIsInProgress() throws Exception {
        slowOutboundWrite = true;
        assertThat(subscribeAndAwaitErrorFrame()).isEqualTo("Unauthenticated");
    }

    private String subscribeAndAwaitErrorFrame() throws Exception {
        CompletableFuture<String> errorMessage = new CompletableFuture<>();
        StompSessionHandlerAdapter handler = new StompSessionHandlerAdapter() {
            @Override
            public Type getPayloadType(StompHeaders headers) {
                return byte[].class;
            }

            @Override
            public void handleFrame(StompHeaders headers, Object payload) {
                errorMessage.complete(headers.getFirst("message"));
            }

            @Override
            public void handleTransportError(StompSession session, Throwable exception) {
                errorMessage.complete("<no ERROR frame, transport closed: " + exception + ">");
            }
        };

        WebSocketStompClient client = new WebSocketStompClient(new StandardWebSocketClient());
        client.setMessageConverter(new SimpleMessageConverter());
        try {
            StompSession session = client.connectAsync("ws://localhost:" + port + "/ws", handler)
                    .get(5, TimeUnit.SECONDS);
            session.subscribe("/topic/anything", handler);
            return errorMessage.get(10, TimeUnit.SECONDS);
        }
        finally {
            client.stop();
        }
    }

    /**
     * Test-only session decorator that keeps the outbound write of CONNECTED "in progress" (as a slow
     * client or network would) until the rejected SUBSCRIBE has arrived and the resulting close() has
     * been requested. It sits under ConcurrentWebSocketSessionDecorator, so the flush lock stays held.
     */
    @TestConfiguration
    static class SlowOutboundWriteConfig implements WebSocketMessageBrokerConfigurer {

        private static final Map<String, CountDownLatch[]> latches = new ConcurrentHashMap<>();

        private static CountDownLatch[] latchesFor(WebSocketSession session) {
            return latches.computeIfAbsent(session.getId(), id -> new CountDownLatch[] {new CountDownLatch(1), new CountDownLatch(1)});
        }

        @Override
        public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
            registration.addDecoratorFactory(handler -> new WebSocketHandlerDecorator(handler) {
                @Override
                public void afterConnectionEstablished(WebSocketSession session) throws Exception {
                    super.afterConnectionEstablished(new WebSocketSessionDecorator(session) {
                        @Override
                        public void sendMessage(WebSocketMessage<?> message) throws java.io.IOException {
                            super.sendMessage(message);
                            if (slowOutboundWrite && message.getPayload().toString().startsWith("CONNECTED")) {
                                awaitQuietly(latchesFor(this)[0], 5000);   // SUBSCRIBE received
                                awaitQuietly(latchesFor(this)[1], 1000);   // close() requested
                            }
                        }

                        @Override
                        public void close(CloseStatus status) throws java.io.IOException {
                            latchesFor(this)[1].countDown();
                            super.close(status);
                        }
                    });
                }

                @Override
                public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
                    if (message.getPayload().toString().startsWith("SUBSCRIBE")) {
                        latchesFor(session)[0].countDown();
                    }
                    super.handleMessage(session, message);
                }
            });
        }

        private static void awaitQuietly(CountDownLatch latch, long millis) {
            try {
                latch.await(millis, TimeUnit.MILLISECONDS);
            }
            catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    in: webIssues in web modules (web, webmvc, webflux, websocket)status: waiting-for-triageAn issue we've not yet triaged or decided on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions