com.sun.faces.push.WebsocketSessionManager Maven / Gradle / Ivy
Show all versions of jakarta.faces Show documentation
/*
* Copyright (c) 1997, 2020 Oracle and/or its affiliates. All rights reserved.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0, which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the
* Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
* version 2 with the GNU Classpath Exception, which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
*/
package com.sun.faces.push;
import static com.sun.faces.cdi.CdiUtils.getBeanReference;
import static com.sun.faces.push.WebsocketEndpoint.PARAM_CHANNEL;
import static jakarta.websocket.CloseReason.CloseCodes.NORMAL_CLOSURE;
import static java.lang.String.format;
import static java.util.Collections.emptySet;
import static java.util.logging.Level.WARNING;
import java.io.IOException;
import java.io.Serializable;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.logging.Logger;
import com.sun.faces.util.Util;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.util.AnnotationLiteral;
import jakarta.faces.context.FacesContext;
import jakarta.faces.event.WebsocketEvent;
import jakarta.faces.event.WebsocketEvent.Closed;
import jakarta.faces.event.WebsocketEvent.Opened;
import jakarta.faces.push.Push;
import jakarta.inject.Inject;
import jakarta.websocket.CloseReason;
import jakarta.websocket.Session;
/**
*
* This web socket session manager holds all web socket sessions by their channel identifier.
*
* @author Bauke Scholtz
* @see Push
* @since 2.3
*/
@ApplicationScoped
public class WebsocketSessionManager {
// Constants ------------------------------------------------------------------------------------------------------
private static final Logger logger = Logger.getLogger(WebsocketSessionManager.class.getName());
private static final CloseReason REASON_EXPIRED = new CloseReason(NORMAL_CLOSURE, "Expired");
private static final AnnotationLiteral SESSION_OPENED = new AnnotationLiteral() {
private static final long serialVersionUID = 1L;
};
private static final AnnotationLiteral SESSION_CLOSED = new AnnotationLiteral() {
private static final long serialVersionUID = 1L;
};
private static final long TOMCAT_WEB_SOCKET_RETRY_TIMEOUT = 10; // Milliseconds.
private static final long TOMCAT_WEB_SOCKET_MAX_RETRIES = 100; // So, that's retrying for about 1 second.
private static final String WARNING_TOMCAT_WEB_SOCKET_BOMBED = "Tomcat cannot handle concurrent push messages."
+ " A push message has been sent only after %s retries of " + TOMCAT_WEB_SOCKET_RETRY_TIMEOUT + "ms apart."
+ " Consider rate limiting sending push messages. For example, once every 500ms.";
private static final String ERROR_TOMCAT_WEB_SOCKET_BOMBED = "Tomcat cannot handle concurrent push messages."
+ " A push message could NOT be sent after %s retries of " + TOMCAT_WEB_SOCKET_RETRY_TIMEOUT + "ms apart."
+ " Consider rate limiting sending push messages. For example, once every 500ms.";
// Properties -----------------------------------------------------------------------------------------------------
private final ConcurrentMap> socketSessions = new ConcurrentHashMap<>();
@Inject
private WebsocketUserManager socketUsers;
// Actions --------------------------------------------------------------------------------------------------------
/**
* Register given channel identifier.
*
* @param channelId The channel identifier to register.
*/
protected void register(String channelId) {
if (!socketSessions.containsKey(channelId)) {
socketSessions.putIfAbsent(channelId, new ConcurrentLinkedQueue());
}
}
/**
* Register given channel identifiers.
*
* @param channelIds The channel identifiers to register.
*/
protected void register(Iterable channelIds) {
for (String channelId : channelIds) {
register(channelId);
}
}
/**
* On open, add given web socket session to the mapping associated with its channel identifier and returns
* true
if it's accepted (i.e. the channel identifier is known) and the same session hasn't been added
* before, otherwise false
.
*
* @param session The opened web socket session.
* @return true
if given web socket session is accepted and is new, otherwise false
.
*/
protected boolean add(Session session) {
String channelId = getChannelId(session);
Collection sessions = socketSessions.get(channelId);
if (sessions != null && sessions.add(session)) {
Serializable user = socketUsers.getUser(getChannel(session), channelId);
if (user != null) {
session.getUserProperties().put("user", user);
}
fireEvent(session, null, SESSION_OPENED);
return true;
}
return false;
}
/**
* Encode the given message object as JSON and send it to all open web socket sessions associated with given web socket
* channel identifier.
*
* @param channelId The web socket channel identifier.
* @param message The push message string.
* @return The results of the send operation. If it returns an empty set, then there was no open session associated with
* given channel identifier. The returned futures will return null
on {@link Future#get()} if the message
* was successfully delivered and otherwise throw {@link ExecutionException}.
*/
protected Set> send(String channelId, String message) {
Collection sessions = channelId != null ? socketSessions.get(channelId) : null;
if (sessions != null && !sessions.isEmpty()) {
Set> results = new HashSet<>(sessions.size());
for (Session session : sessions) {
if (session.isOpen()) {
results.add(send(session, message, true));
}
}
return results;
}
return emptySet();
}
private Future send(Session session, String text, boolean retrySendTomcatWebSocket) {
try {
return session.getAsyncRemote().sendText(text);
} catch (IllegalStateException e) {
// Awkward workaround for Tomcat not willing to queue/synchronize asyncRemote().
// https://bz.apache.org/bugzilla/show_bug.cgi?id=56026
if (session.getClass().getName().startsWith("org.apache.tomcat.websocket.") && e.getMessage().contains("[TEXT_FULL_WRITING]")) {
if (retrySendTomcatWebSocket) {
return CompletableFuture.supplyAsync(() -> retrySendTomcatWebSocket(session, text));
} else {
return null;
}
} else {
throw e;
}
}
}
private Void retrySendTomcatWebSocket(Session session, String text) {
int retries = 0;
Exception cause = null;
while (++retries < TOMCAT_WEB_SOCKET_MAX_RETRIES) {
try {
Thread.sleep(TOMCAT_WEB_SOCKET_RETRY_TIMEOUT);
if (!session.isOpen()) {
cause = new IllegalStateException("Too bad, session is now closed");
break;
}
Future result = send(session, text, false);
if (result == null) {
continue;
}
if (logger.isLoggable(WARNING)) {
logger.log(WARNING, format(WARNING_TOMCAT_WEB_SOCKET_BOMBED, retries));
}
return result.get();
} catch (InterruptedException | ExecutionException e) {
Thread.currentThread().interrupt();
cause = e;
break;
}
}
throw new UnsupportedOperationException(format(ERROR_TOMCAT_WEB_SOCKET_BOMBED, retries), cause);
}
/**
* On close, remove given web socket session from the mapping.
*
* @param session The closed web socket session.
* @param reason The close reason.
*/
protected void remove(Session session, CloseReason reason) {
Collection sessions = socketSessions.get(getChannelId(session));
if (sessions != null && sessions.remove(session)) {
fireEvent(session, reason, SESSION_CLOSED);
}
}
/**
* Deregister given channel identifiers and explicitly close all open web socket sessions associated with it.
*
* @param channelIds The channel identifiers to deregister.
*/
protected void deregister(Iterable channelIds) {
for (String channelId : channelIds) {
Collection sessions = socketSessions.remove(channelId);
if (sessions != null) {
for (Session session : sessions) {
if (session.isOpen()) {
try {
session.close(REASON_EXPIRED);
} catch (IOException ignore) {
continue;
}
}
}
}
}
}
// Internal -------------------------------------------------------------------------------------------------------
private static volatile WebsocketSessionManager instance;
/**
* Internal usage only. Awkward workaround for it being unavailable via @Inject in endpoint in Tomcat+Weld/OWB.
*/
static WebsocketSessionManager getInstance() {
if (instance == null) {
instance = getBeanReference(WebsocketSessionManager.class);
}
return instance;
}
// Helpers --------------------------------------------------------------------------------------------------------
private static String getChannel(Session session) {
return session.getPathParameters().get(PARAM_CHANNEL);
}
private static String getChannelId(Session session) {
return session.getQueryString();
}
private static void fireEvent(Session session, CloseReason reason, AnnotationLiteral> qualifier) {
Serializable user = (Serializable) session.getUserProperties().get("user");
Util.getCdiBeanManager(FacesContext.getCurrentInstance()).getEvent().select(WebsocketEvent.class, qualifier)
.fire(new WebsocketEvent(getChannel(session), user, reason != null ? reason.getCloseCode() : null));
}
}