11/*
2- * Copyright (C) 2023 European Spallation Source ERIC.
3- *
4- * This program is free software; you can redistribute it and/or
5- * modify it under the terms of the GNU General Public License
6- * as published by the Free Software Foundation; either version 2
7- * of the License, or (at your option) any later version.
8- *
9- * This program is distributed in the hope that it will be useful,
10- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12- * GNU General Public License for more details.
13- *
14- * You should have received a copy of the GNU General Public License
15- * along with this program; if not, write to the Free Software
16- * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
2+ * Copyright (C) 2025 European Spallation Source ERIC.
173 *
184 */
195
2410import org .phoebus .applications .saveandrestore .model .websocket .SaveAndRestoreWebSocketMessage ;
2511import org .springframework .beans .factory .annotation .Autowired ;
2612import org .springframework .lang .NonNull ;
13+ import org .springframework .scheduling .annotation .Scheduled ;
2714import org .springframework .stereotype .Component ;
2815import org .springframework .web .socket .CloseStatus ;
2916import org .springframework .web .socket .PongMessage ;
3320
3421import javax .annotation .PreDestroy ;
3522import java .io .EOFException ;
23+ import java .net .InetSocketAddress ;
24+ import java .time .Instant ;
25+ import java .time .temporal .ChronoUnit ;
3626import java .util .ArrayList ;
27+ import java .util .Collections ;
3728import java .util .List ;
3829import java .util .Optional ;
3930import java .util .logging .Level ;
4031import java .util .logging .Logger ;
4132
4233/**
4334 * Single web socket end-point routing messages to active {@link WebSocket} instances.
35+ *
36+ * <p>
37+ * In some cases web socket clients may become stale/disconnected for various reasons, e.g. network issues. The
38+ * {@link #afterConnectionClosed(WebSocketSession, CloseStatus)} is not necessarily called in those case.
39+ * To make sure the {@link #sockets} collection does not contain stale clients, a scheduled job runs once per hour to
40+ * ping all clients, and set the time when the pong response was received. Another scheduled job will check
41+ * the last received pong message timestamp and - if older than 70 minutes - consider the client session dead
42+ * and dispose of it.
43+ * </p>
4444 */
4545@ Component
4646public class WebSocketHandler extends TextWebSocketHandler {
@@ -49,7 +49,7 @@ public class WebSocketHandler extends TextWebSocketHandler {
4949 * List of active {@link WebSocket}
5050 */
5151 @ SuppressWarnings ("unused" )
52- private List <WebSocket > sockets = new ArrayList <>();
52+ private List <WebSocket > sockets = Collections . synchronizedList ( new ArrayList <>() );
5353
5454 @ SuppressWarnings ("unused" )
5555 @ Autowired
@@ -87,7 +87,8 @@ public void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMe
8787 */
8888 @ Override
8989 public void afterConnectionEstablished (@ NonNull WebSocketSession session ) {
90- logger .log (Level .INFO , "Opening web socket session from remote " + session .getRemoteAddress ().getAddress ());
90+ InetSocketAddress inetSocketAddress = session .getRemoteAddress ();
91+ logger .log (Level .INFO , "Opening web socket session from remote " + (inetSocketAddress != null ? inetSocketAddress .getAddress ().toString () : "<unknown IP address>" ));
9192 WebSocket webSocket = new WebSocket (objectMapper , session );
9293 sockets .add (webSocket );
9394 }
@@ -104,7 +105,7 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl
104105 Optional <WebSocket > webSocketOptional =
105106 sockets .stream ().filter (webSocket -> webSocket .getId ().equals (session .getId ())).findFirst ();
106107 if (webSocketOptional .isPresent ()) {
107- logger .log (Level .INFO , "Closing web socket session from remote " + session . getRemoteAddress ().getAddress ());
108+ logger .log (Level .INFO , "Closing web socket session " + webSocketOptional . get ().getDescription ());
108109 webSocketOptional .get ().dispose ();
109110 sockets .remove (webSocketOptional .get ());
110111 }
@@ -126,20 +127,20 @@ public void handleTransportError(@NonNull WebSocketSession session, @NonNull Thr
126127 }
127128
128129 /**
129- * Called when client sends ping message, i.e. a pong message is sent and time for last message
130+ * Called when client sends ping message, i.e. a pong message is sent and time for last pong response message
130131 * in the {@link WebSocket} instance is refreshed.
131132 *
132133 * @param session Associated {@link WebSocketSession}
133134 * @param message See {@link PongMessage}
134135 */
135136 @ Override
136137 protected void handlePongMessage (@ NonNull WebSocketSession session , @ NonNull PongMessage message ) {
137- logger .log (Level .INFO , "Got pong" );
138+ logger .log (Level .FINE , "Got pong for session " + session . getId () );
138139 // Find the WebSocket instance associated with this WebSocketSession
139140 Optional <WebSocket > webSocketOptional =
140141 sockets .stream ().filter (webSocket -> webSocket .getId ().equals (session .getId ())).findFirst ();
141- if (webSocketOptional .isEmpty ()) {
142- return ; // Should only happen in case of timing issues?
142+ if (webSocketOptional .isPresent ()) {
143+ webSocketOptional . get (). setLastPinged ( Instant . now ());
143144 }
144145 }
145146
@@ -156,7 +157,7 @@ private String shorten(final String message) {
156157 @ PreDestroy
157158 public void cleanup () {
158159 sockets .forEach (s -> {
159- logger .log (Level .INFO , "Disposing socket " + s .getId ());
160+ logger .log (Level .INFO , "Disposing socket " + s .getDescription ());
160161 s .dispose ();
161162 });
162163 }
@@ -170,4 +171,41 @@ public void sendMessage(SaveAndRestoreWebSocketMessage webSocketMessage) {
170171 }
171172 });
172173 }
174+
175+ /**
176+ * Sends a ping message to all clients contained in {@link #sockets}.
177+ * <p>
178+ * This is scheduled to run at the top of each hour, i.e. 00.00, 01.00...23.00
179+ * </p>
180+ *
181+ */
182+ @ SuppressWarnings ("unused" )
183+ @ Scheduled (cron = "* 0 * * * *" )
184+ public void pingClients (){
185+ sockets .forEach (WebSocket ::sendPing );
186+ }
187+
188+ /**
189+ * For each client in {@link #sockets}, checks the timestamp of last received pong message. If this is older
190+ * than 70 minutes, the socket is considered dead, and then disposed.
191+ * <p>
192+ * This is scheduled to run 5 minutes past each hour, i.e. 00.05, 01.05...23.05
193+ * </p>
194+ *
195+ */
196+ @ SuppressWarnings ("unused" )
197+ @ Scheduled (cron = "* 5 * * * *" )
198+ public void cleanUpDeadSockets (){
199+ List <WebSocket > deadSockets = new ArrayList <>();
200+ Instant now = Instant .now ();
201+ sockets .forEach (s -> {
202+ if (s .getLastPinged () != null && s .getLastPinged ().isBefore (now .minus (70 , ChronoUnit .MINUTES ))){
203+ deadSockets .add (s );
204+ }
205+ });
206+ deadSockets .forEach (d -> {
207+ sockets .remove (d );
208+ d .dispose ();
209+ });
210+ }
173211}
0 commit comments