pl.morgwai.base.servlet.utils.WebsocketPingerService Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of servlet-utils Show documentation
Show all versions of servlet-utils Show documentation
Some helpful classes when developing servlets
// Copyright (c) Piotr Morgwai Kotarbinski, Licensed under the Apache License, Version 2.0
package pl.morgwai.base.servlet.utils;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.websocket.*;
import javax.websocket.CloseReason.CloseCodes;
import javax.websocket.RemoteEndpoint.Async;
/**
* Automatically pings and handles pongs from websocket {@link Session connections}. Depending on
* constructor used, operates in either {@link #WebsocketPingerService(int, int, int, boolean)
* verify-pongs mode} or {@link #WebsocketPingerService(int, boolean) keep-alive-only mode}. The
* service can be used both on a client and a server side.
*
* Apps usually create service instances at their startups and store them in locations easily
* reachable for {@code Endpoint} instances or the code that manages them (for example as a
* {@code ServletContext} attribute, a member variable in a class that creates client connections or
* some static var).
* At an app shutdown {@link #stop()} should be called to terminate the pinging thread.
*
* Connections can be registered for pinging using {@link #addConnection(Session)}
* and deregister using {@link #removeConnection(Session)}.
*/
public class WebsocketPingerService {
/** Majority of proxy and NAT routers have timeout of at least 60s. */
public static final int DEFAULT_INTERVAL = 55;
final int intervalSeconds;
/** Arbitrarily chosen number. */
public static final int DEFAULT_FAILURE_LIMIT = 4;
final int failureLimit;
/** Economic value to reduce use of {@link Random}. */
public static final int DEFAULT_PING_SIZE = 4;
final int pingSize;
final boolean synchronizeSending;
final boolean keepAliveOnly;
final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
final ScheduledFuture pingingTask;
final Random random = new Random();
final ConcurrentMap connectionPingPongPlayers =
new ConcurrentHashMap<>();
/** Low-level constructor for both modes */
private WebsocketPingerService(
boolean keepAliveOnly,
int intervalSeconds,
int failureLimit,
int pingSize,
boolean synchronizeSending
) {
if (pingSize > 125 || pingSize < 1) {
throw new IllegalArgumentException("ping size must be between 1 and 125");
}
if ( ( !keepAliveOnly) && failureLimit < 0) {
throw new IllegalArgumentException("failure limit cannot be negative");
}
this.keepAliveOnly = keepAliveOnly;
this.intervalSeconds = intervalSeconds;
this.failureLimit = failureLimit;
this.pingSize = pingSize;
this.synchronizeSending = synchronizeSending;
pingingTask = scheduler.scheduleAtFixedRate(
this::pingAllConnections, 0L, intervalSeconds, TimeUnit.SECONDS);
}
/**
* Configures and starts the service in verify-pongs mode: timely pongs are expected and their
* contents is verified to match corresponding pings.
* @param intervalSeconds interval between pings and also timeout for pongs.
* @param failureLimit limit of lost, malformed or timed out pongs after which the given
* connection is closed. Each valid, timely pong resets connection's failure counter.
* @param pingSize size of the ping data to send. This comes from {@link Random}, so an economic
* value is recommended.
* @param synchronizeSending whether to synchronize packet sending on the given connection.
* Whether it is necessary depends on the implementation of the container. For example it is
* not necessary on Jetty, but it is on Tomcat: see
* this bug report.
*/
public WebsocketPingerService(
int intervalSeconds, int failureLimit, int pingSize, boolean synchronizeSending) {
this(false, intervalSeconds, failureLimit, pingSize, synchronizeSending);
}
/**
* Calls {@link #WebsocketPingerService(int, int, int, boolean)
* WebsocketPingerService(intervalSeconds, failureLimit, pingSize, false)} (ping-pong mode).
*/
public WebsocketPingerService(int intervalSeconds, int failureLimit, int pingSize) {
this(intervalSeconds, failureLimit, pingSize, false);
}
/**
* Calls {@link #WebsocketPingerService(int, int, int, boolean)
* WebsocketPingerService}({@link #DEFAULT_INTERVAL},
* {@link #DEFAULT_FAILURE_LIMIT}, {@link #DEFAULT_PING_SIZE}, false)
(ping-pong mode).
*/
public WebsocketPingerService() {
this(DEFAULT_INTERVAL, DEFAULT_FAILURE_LIMIT, DEFAULT_PING_SIZE, false);
}
/**
* Configures and starts the service in keep-alive-only mode: just 1-byte ping is sent each time
* and responses are not verified. The params have the same meaning as in
* {@link #WebsocketPingerService(int, int, int, boolean)}.
*/
public WebsocketPingerService(int intervalSeconds, boolean synchronizeSending) {
this(true, intervalSeconds, -1, 1, synchronizeSending);
}
/**
* Calls {@link #WebsocketPingerService(int, boolean)
* WebsocketPingerService}(intervalSeconds, false)
* (keep-alive-only mode).
*/
public WebsocketPingerService(int intervalSeconds) { this(intervalSeconds, false); }
/**
* Registers {@code connection} for pinging by this service. Usually called in
* {@link javax.websocket.Endpoint#onOpen(Session, javax.websocket.EndpointConfig)}.
*/
public void addConnection(Session connection) {
connectionPingPongPlayers.put(
connection,
new PingPongPlayer(connection, failureLimit, synchronizeSending)
);
}
/**
* Removes {@code connection} from this service, so it will not be pinged anymore. Usually
* called in {@link javax.websocket.Endpoint#onClose(Session, CloseReason)}.
* @return {@code true} if {@code connection} had been {@link #addConnection(Session) added} to
* this service before and has been successfully removed by this method, {@code false} if it
* had not been added and no action has taken place.
*/
public boolean removeConnection(Session connection) {
final var pingPongPlayer = connectionPingPongPlayers.remove(connection);
if (pingPongPlayer != null) {
pingPongPlayer.deregister();
return true;
} else {
return false;
}
}
/** Returns the number of currently registered connections. */
public int getNumberOfConnections() {
return connectionPingPongPlayers.size();
}
/** {@link #pingingTask} */
void pingAllConnections() {
final var connectionIterator = connectionPingPongPlayers.keySet().iterator();
if ( !connectionIterator.hasNext()) return;
final var pingData = new byte[pingSize];
if (keepAliveOnly) {
pingData[0] = (byte) connectionIterator.next().hashCode();
} else {
random.nextBytes(pingData);
}
for (var pingPongPlayer: connectionPingPongPlayers.values()) {
pingPongPlayer.sendPing(pingData);
}
}
/**
* Stops the service. After a call to this method the service becomes no longer usable and
* should be discarded.
* @return connections that were registered at the time this method was called.
*/
public Set stop() {
pingingTask.cancel(true);
scheduler.shutdown();
for (var pingPongPlayer: connectionPingPongPlayers.values()) pingPongPlayer.deregister();
try {
scheduler.awaitTermination(500L, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {}
if ( !scheduler.isTerminated()) {
scheduler.shutdownNow();
try {
scheduler.awaitTermination(50L, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {}
if ( !scheduler.isTerminated()) log.warning("service's executor failed to terminate");
}
return connectionPingPongPlayers.keySet();
}
/** Plays ping-pong with a single associated connection. */
static class PingPongPlayer implements MessageHandler.Whole {
final Session connection;
final Async connector;
final int failureLimit; // negative value means keep-alive-only mode
final boolean synchronizeSending;
ByteBuffer pingDataBuffer;
int failureCount = 0;
boolean awaitingPong = false;
PingPongPlayer(Session connection, int failureLimit, boolean synchronizeSending) {
this.connection = connection;
this.connector = connection.getAsyncRemote();
this.synchronizeSending = synchronizeSending;
this.failureLimit = failureLimit;
connection.addMessageHandler(PongMessage.class, this);
}
/** Called by the service's worker thread. */
synchronized void sendPing(byte[] pingData) {
if (awaitingPong) { // the previous ping timed out
failureCount++;
if (failureCount > failureLimit) {
closeFailedConnection();
return;
}
}
pingDataBuffer = ByteBuffer.wrap(pingData);
try {
if (synchronizeSending) {
synchronized (connection) {
connector.sendPing(pingDataBuffer);
}
} else {
connector.sendPing(pingDataBuffer);
}
pingDataBuffer.rewind();
if (failureLimit >= 0) awaitingPong = true;
} catch (IOException e) {
closeFailedConnection();
}
}
@Override
public synchronized void onMessage(PongMessage pong) {
if (failureLimit < 0 || !awaitingPong) return; // keep-alive only
awaitingPong = false;
if (pong.getApplicationData().equals(pingDataBuffer)) {
failureCount = 0;
} else {
failureCount++;
if (failureCount > failureLimit) closeFailedConnection();
}
}
/** Called if ping {@link #failureLimit} is exceeded or if {@link IOException} occurs. */
private void closeFailedConnection() {
if (log.isLoggable(Level.FINE)) log.fine("failure on connection " + connection.getId());
try {
connection.close(new CloseReason(
CloseCodes.PROTOCOL_ERROR, "ping failure limit exceeded"));
} catch (IOException ignored) {}
}
/** Removes pong handler. Called by the service when connection is deregistered. */
void deregister() {
connection.removeMessageHandler(this);
}
}
static final Logger log = Logger.getLogger(WebsocketPingerService.class.getName());
}