/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.socket.sockjs.transport.session;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.util.Assert;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.sockjs.SockJsMessageDeliveryException;
import org.springframework.web.socket.sockjs.SockJsTransportFailureException;
import org.springframework.web.socket.sockjs.frame.SockJsFrame;
import org.springframework.web.socket.sockjs.frame.SockJsMessageCodec;
import org.springframework.web.socket.sockjs.transport.SockJsServiceConfig;
import org.springframework.web.socket.sockjs.transport.SockJsSession;
/**
* An abstract base class for SockJS sessions implementing {@link SockJsSession}.
*
* @author Rossen Stoyanchev
* @author Sam Brannen
* @since 4.0
*/
public abstract class AbstractSockJsSession implements SockJsSession {
private enum State {NEW, OPEN, CLOSED}
/**
* Log category to use on network IO exceptions after a client has gone away.
* <p>The Servlet API does not provide notifications when a client disconnects;
* see <a href="https://java.net/jira/browse/SERVLET_SPEC-44">SERVLET_SPEC-44</a>.
* Therefore network IO failures may occur simply because a client has gone away,
* and that can fill the logs with unnecessary stack traces.
* <p>We make a best effort to identify such network failures, on a per-server
* basis, and log them under a separate log category. A simple one-line message
* is logged at DEBUG level, while a full stack trace is shown at TRACE level.
* @see #disconnectedClientLogger
*/
public static final String DISCONNECTED_CLIENT_LOG_CATEGORY =
"org.springframework.web.socket.sockjs.DisconnectedClient";
/**
* Tomcat: ClientAbortException or EOFException
* Jetty: EofException
* WildFly, GlassFish: java.io.IOException "Broken pipe" (already covered)
* <p>TODO:
* This definition is currently duplicated between HttpWebHandlerAdapter
* and AbstractSockJsSession. It is a candidate for a common utility class.
* @see #indicatesDisconnectedClient(Throwable)
*/
private static final Set<String> DISCONNECTED_CLIENT_EXCEPTIONS =
new HashSet<>(Arrays.asList("ClientAbortException", "EOFException", "EofException"));
/**
* Separate logger to use on network IO failure after a client has gone away.
* @see #DISCONNECTED_CLIENT_LOG_CATEGORY
*/
protected static final Log disconnectedClientLogger = LogFactory.getLog(DISCONNECTED_CLIENT_LOG_CATEGORY);
protected final Log logger = LogFactory.getLog(getClass());
protected final Object responseLock = new Object();
private final String id;
private final SockJsServiceConfig config;
private final WebSocketHandler handler;
private final Map<String, Object> attributes = new ConcurrentHashMap<>();
private volatile State state = State.NEW;
private final long timeCreated = System.currentTimeMillis();
private volatile long timeLastActive = this.timeCreated;
private ScheduledFuture<?> heartbeatFuture;
private HeartbeatTask heartbeatTask;
private volatile boolean heartbeatDisabled;
/**
* Create a new instance.
* @param id the session ID
* @param config SockJS service configuration options
* @param handler the recipient of SockJS messages
* @param attributes attributes from the HTTP handshake to associate with the WebSocket
* session; the provided attributes are copied, the original map is not used.
*/
public AbstractSockJsSession(String id, SockJsServiceConfig config, WebSocketHandler handler,
Map<String, Object> attributes) {
Assert.notNull(id, "SessionId must not be null");
Assert.notNull(config, "SockJsConfig must not be null");
Assert.notNull(handler, "WebSocketHandler must not be null");
this.id = id;
this.config = config;
this.handler = handler;
if (attributes != null) {
this.attributes.putAll(attributes);
}
}
@Override
public String getId() {
return this.id;
}
protected SockJsMessageCodec getMessageCodec() {
return this.config.getMessageCodec();
}
public SockJsServiceConfig getSockJsServiceConfig() {
return this.config;
}
@Override
public Map<String, Object> getAttributes() {
return this.attributes;
}
// Message sending
public final void sendMessage(WebSocketMessage<?> message) throws IOException {
Assert.state(!isClosed(), "Cannot send a message when session is closed");
Assert.isInstanceOf(TextMessage.class, message, "SockJS supports text messages only");
sendMessageInternal(((TextMessage) message).getPayload());
}
protected abstract void sendMessageInternal(String message) throws IOException;
// Lifecycle related methods
public boolean isNew() {
return State.NEW.equals(this.state);
}
@Override
public boolean isOpen() {
return State.OPEN.equals(this.state);
}
public boolean isClosed() {
return State.CLOSED.equals(this.state);
}
/**
* Performs cleanup and notify the {@link WebSocketHandler}.
*/
@Override
public final void close() throws IOException {
close(new CloseStatus(3000, "Go away!"));
}
/**
* Performs cleanup and notify the {@link WebSocketHandler}.
*/
@Override
public final void close(CloseStatus status) throws IOException {
if (isOpen()) {
if (logger.isDebugEnabled()) {
logger.debug("Closing SockJS session " + getId() + " with " + status);
}
this.state = State.CLOSED;
try {
if (isActive() && !CloseStatus.SESSION_NOT_RELIABLE.equals(status)) {
try {
writeFrameInternal(SockJsFrame.closeFrame(status.getCode(), status.getReason()));
}
catch (Throwable ex) {
logger.debug("Failure while sending SockJS close frame", ex);
}
}
updateLastActiveTime();
cancelHeartbeat();
disconnect(status);
}
finally {
try {
this.handler.afterConnectionClosed(this, status);
}
catch (Throwable ex) {
logger.debug("Error from WebSocketHandler.afterConnectionClosed in " + this, ex);
}
}
}
}
@Override
public long getTimeSinceLastActive() {
if (isNew()) {
return (System.currentTimeMillis() - this.timeCreated);
}
else {
return (isActive() ? 0 : System.currentTimeMillis() - this.timeLastActive);
}
}
/**
* Should be invoked whenever the session becomes inactive.
*/
protected void updateLastActiveTime() {
this.timeLastActive = System.currentTimeMillis();
}
@Override
public void disableHeartbeat() {
this.heartbeatDisabled = true;
cancelHeartbeat();
}
protected void sendHeartbeat() throws SockJsTransportFailureException {
synchronized (this.responseLock) {
if (isActive() && !this.heartbeatDisabled) {
writeFrame(SockJsFrame.heartbeatFrame());
scheduleHeartbeat();
}
}
}
protected void scheduleHeartbeat() {
if (this.heartbeatDisabled) {
return;
}
synchronized (this.responseLock) {
cancelHeartbeat();
if (!isActive()) {
return;
}
Date time = new Date(System.currentTimeMillis() + this.config.getHeartbeatTime());
this.heartbeatTask = new HeartbeatTask();
this.heartbeatFuture = this.config.getTaskScheduler().schedule(this.heartbeatTask, time);
if (logger.isTraceEnabled()) {
logger.trace("Scheduled heartbeat in session " + getId());
}
}
}
protected void cancelHeartbeat() {
synchronized (this.responseLock) {
if (this.heartbeatFuture != null) {
if (logger.isTraceEnabled()) {
logger.trace("Cancelling heartbeat in session " + getId());
}
this.heartbeatFuture.cancel(false);
this.heartbeatFuture = null;
}
if (this.heartbeatTask != null) {
this.heartbeatTask.cancel();
this.heartbeatTask = null;
}
}
}
/**
* Polling and Streaming sessions periodically close the current HTTP request and
* wait for the next request to come through. During this "downtime" the session is
* still open but inactive and unable to send messages and therefore has to buffer
* them temporarily. A WebSocket session by contrast is stateful and remain active
* until closed.
*/
public abstract boolean isActive();
/**
* Actually close the underlying WebSocket session or in the case of HTTP
* transports complete the underlying request.
*/
protected abstract void disconnect(CloseStatus status) throws IOException;
// Frame writing
/**
* For internal use within a TransportHandler and the (TransportHandler-specific)
* session class.
*/
protected void writeFrame(SockJsFrame frame) throws SockJsTransportFailureException {
if (logger.isTraceEnabled()) {
logger.trace("Preparing to write " + frame);
}
try {
writeFrameInternal(frame);
}
catch (Throwable ex) {
logWriteFrameFailure(ex);
try {
// Force disconnect (so we won't try to send close frame)
disconnect(CloseStatus.SERVER_ERROR);
}
catch (Throwable disconnectFailure) {
// Ignore
}
try {
close(CloseStatus.SERVER_ERROR);
}
catch (Throwable closeFailure) {
// Nothing of consequence, already forced disconnect
}
throw new SockJsTransportFailureException("Failed to write " + frame, getId(), ex);
}
}
protected abstract void writeFrameInternal(SockJsFrame frame) throws IOException;
private void logWriteFrameFailure(Throwable ex) {
if (indicatesDisconnectedClient(ex)) {
if (disconnectedClientLogger.isTraceEnabled()) {
disconnectedClientLogger.trace("Looks like the client has gone away", ex);
}
else if (disconnectedClientLogger.isDebugEnabled()) {
disconnectedClientLogger.debug("Looks like the client has gone away: " + ex +
" (For a full stack trace, set the log category '" + DISCONNECTED_CLIENT_LOG_CATEGORY +
"' to TRACE level.)");
}
}
else {
logger.debug("Terminating connection after failure to send message to client", ex);
}
}
private boolean indicatesDisconnectedClient(Throwable ex) {
return ("Broken pipe".equalsIgnoreCase(NestedExceptionUtils.getMostSpecificCause(ex).getMessage()) ||
DISCONNECTED_CLIENT_EXCEPTIONS.contains(ex.getClass().getSimpleName()));
}
// Delegation methods
public void delegateConnectionEstablished() throws Exception {
this.state = State.OPEN;
this.handler.afterConnectionEstablished(this);
}
public void delegateMessages(String... messages) throws SockJsMessageDeliveryException {
List<String> undelivered = new ArrayList<>(Arrays.asList(messages));
for (String message : messages) {
try {
if (isClosed()) {
throw new SockJsMessageDeliveryException(this.id, undelivered, "Session closed");
}
else {
this.handler.handleMessage(this, new TextMessage(message));
undelivered.remove(0);
}
}
catch (Throwable ex) {
throw new SockJsMessageDeliveryException(this.id, undelivered, ex);
}
}
}
/**
* Invoked when the underlying connection is closed.
*/
public final void delegateConnectionClosed(CloseStatus status) throws Exception {
if (!isClosed()) {
try {
updateLastActiveTime();
// Avoid cancelHeartbeat() and responseLock within server "close" callback
ScheduledFuture<?> future = this.heartbeatFuture;
if (future != null) {
this.heartbeatFuture = null;
future.cancel(false);
}
}
finally {
this.state = State.CLOSED;
this.handler.afterConnectionClosed(this, status);
}
}
}
/**
* Close due to error arising from SockJS transport handling.
*/
public void tryCloseWithSockJsTransportError(Throwable error, CloseStatus closeStatus) {
if (logger.isDebugEnabled()) {
logger.debug("Closing due to transport error for " + this);
}
try {
delegateError(error);
}
catch (Throwable delegateException) {
// Ignore
logger.debug("Exception from error handling delegate", delegateException);
}
try {
close(closeStatus);
}
catch (Throwable closeException) {
logger.debug("Failure while closing " + this, closeException);
}
}
public void delegateError(Throwable ex) throws Exception {
this.handler.handleTransportError(this, ex);
}
// Self description
@Override
public String toString() {
return getClass().getSimpleName() + "[id=" + getId() + "]";
}
private class HeartbeatTask implements Runnable {
private boolean expired;
@Override
public void run() {
synchronized (responseLock) {
if (!this.expired && !isClosed()) {
try {
sendHeartbeat();
}
catch (Throwable ex) {
// Ignore: already handled in writeFrame...
}
finally {
this.expired = true;
}
}
}
}
void cancel() {
this.expired = true;
}
}
}