From cb96be14df77c90ce29db1200e4361d7b81b41ca Mon Sep 17 00:00:00 2001 From: Isaries Date: Tue, 27 Oct 2026 20:02:27 +0800 Subject: [PATCH] fix(WebSocket): send STOMP heartbeats from the simple broker The simple broker was enabled without heartbeats, so the CONNECTED frame advertised heart-beat:0,0 and an idle connection carried no traffic. A reverse proxy in front of the API closes such a connection once its read timeout passes (60 seconds by default in nginx). The simple broker keeps nothing for a subscriber that is not connected, so messages published before the client reconnects are lost, e.g. student status updates in the Classroom Monitor. The broker now sends a heartbeat every 20 seconds and requires none from clients, since browsers throttle timers in background tabs and a required client heartbeat would get those connections closed. Heartbeats are scheduled on Spring's own messageBrokerTaskScheduler. --- .../portal/spring/impl/WebSocketConfig.java | 20 ++++++++++++- .../spring/impl/WebSocketConfigTest.java | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/wise/portal/spring/impl/WebSocketConfigTest.java diff --git a/src/main/java/org/wise/portal/spring/impl/WebSocketConfig.java b/src/main/java/org/wise/portal/spring/impl/WebSocketConfig.java index bb0f0c1e1..bed179d0a 100644 --- a/src/main/java/org/wise/portal/spring/impl/WebSocketConfig.java +++ b/src/main/java/org/wise/portal/spring/impl/WebSocketConfig.java @@ -1,11 +1,15 @@ package org.wise.portal.spring.impl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.TaskScheduler; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @@ -17,9 +21,23 @@ @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + // Server heartbeats keep idle connections open through proxies. None are required from clients + // because browsers throttle timers in background tabs, which would get those sockets closed. + private static final long[] HEARTBEAT_MILLIS = { 20000, 0 }; + + private TaskScheduler messageBrokerTaskScheduler; + + @Autowired + public void setMessageBrokerTaskScheduler( + @Lazy @Qualifier("messageBrokerTaskScheduler") TaskScheduler messageBrokerTaskScheduler) { + this.messageBrokerTaskScheduler = messageBrokerTaskScheduler; + } + @Override public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/topic"); + config.enableSimpleBroker("/topic") + .setHeartbeatValue(HEARTBEAT_MILLIS) + .setTaskScheduler(messageBrokerTaskScheduler); config.setApplicationDestinationPrefixes("/app"); } diff --git a/src/test/java/org/wise/portal/spring/impl/WebSocketConfigTest.java b/src/test/java/org/wise/portal/spring/impl/WebSocketConfigTest.java new file mode 100644 index 000000000..1672e2bac --- /dev/null +++ b/src/test/java/org/wise/portal/spring/impl/WebSocketConfigTest.java @@ -0,0 +1,30 @@ +package org.wise.portal.spring.impl; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.websocket.servlet.WebSocketMessagingAutoConfiguration; +import org.springframework.messaging.simp.broker.SimpleBrokerMessageHandler; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; + +@SpringJUnitWebConfig(WebSocketConfig.class) +@ImportAutoConfiguration({ JacksonAutoConfiguration.class, WebSocketMessagingAutoConfiguration.class }) +public class WebSocketConfigTest { + + @Autowired + private SimpleBrokerMessageHandler simpleBroker; + + @Test + public void simpleBroker_shouldSendHeartbeatsWithoutRequiringThemFromClients() { + assertArrayEquals(new long[] { 20000, 0 }, simpleBroker.getHeartbeatValue()); + } + + @Test + public void simpleBroker_shouldHaveATaskSchedulerForHeartbeats() { + assertNotNull(simpleBroker.getTaskScheduler()); + } +}